From 15e0654ca4ca43e10a3ef3ed8c94b8f419ad678c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:54:56 +0200 Subject: [PATCH 01/11] Ack Cut 3: failing tests for the sender's flow window in Rust, C#, TypeScript and Python A reliable packet goes on the wire only while its sequence is at most 1,023 above the lowest unacked one and the payload above that sequence stays within 4 MiB. Tests cover the span (P8: a lost packet is still delivered), the byte bound, a lone oversize packet, FIFO admission and the direct-send refusal. They fail on the unbounded window. Co-Authored-By: Claude Sonnet 5.5 --- packages/cultnet-py/tests/test_cultnet.py | 89 +++++++++ packages/cultnet-rs/tests/rudp_flow_window.rs | 172 ++++++++++++++++++ packages/cultnet-ts/test/cultnet.test.ts | 95 ++++++++++ .../NetworkingTests.cs | 114 ++++++++++++ 4 files changed, 470 insertions(+) create mode 100644 packages/cultnet-rs/tests/rudp_flow_window.rs diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index 7e78c48f..2161be04 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -879,6 +879,95 @@ class CultNetTests(unittest.TestCase): self.assertEqual(old_ack.ack, oldest_sequence) self.assertEqual(old_ack.ack_mask, 0) + # The sender's flow window: a reliable packet goes on the wire only while its sequence is at most 1,023 above + # the lowest unacked one and the payload above that sequence stays within 4 MiB. + FLOW_CONNECTION_ID = 0x464C4F57 + MIB = 1024 * 1024 + + def _connected_flow_session(self, initial_sequence: int) -> CultNetRudpSession: + session = CultNetRudpSession( + CultNetRudpSessionOptions(connection_id=self.FLOW_CONNECTION_ID, initial_sequence=initial_sequence) + ) + session.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, self.FLOW_CONNECTION_ID, 0, 0, 0, "control")) + return session + + @staticmethod + def _flow_send(session: CultNetRudpSession, payload_bytes: int) -> tuple[CultNetRudpPacket, ...]: + return session.send_many("state", bytes(payload_bytes), CultNetRudpSendOptions(reliable=True)) + + def _flow_ack(self, sequence: int) -> CultNetRudpPacket: + return CultNetRudpPacket(CultNetRudpPacketType.ACK, self.FLOW_CONNECTION_ID, 0, sequence, 0, "control") + + def _lose_g_and_fill_the_span(self, session: CultNetRudpSession) -> CultNetRudpPacket: + g = self._flow_send(session, 1)[0] + for offset in range(1, 1024): + self.assertEqual([p.sequence for p in self._flow_send(session, 1)], [g.sequence + offset]) + session.receive(self._flow_ack(g.sequence + offset), 1) + return g + + def test_cultnet_rudp_a_lost_packet_holds_the_sender_1023_sequences_ahead_and_is_still_delivered(self) -> None: + sender = self._connected_flow_session(1) + receiver = self._connected_flow_session(100) + g = self._flow_send(sender, 1)[0] + admitted_after_g = 0 + for _ in range(4200): + for packet in self._flow_send(sender, 1): + admitted_after_g += 1 + receiver.receive(packet, 1) + sender.receive(receiver.create_ack(), 1) + self.assertEqual(admitted_after_g, 1023) + self.assertEqual(sender.queued_reliable_packet_count, 4200 - 1023) + + retransmit = next(p for p in sender.due_resends(1000) if p.sequence == g.sequence) + delivered = receiver.receive(retransmit, 1000).delivered + self.assertEqual([frame.sequence for frame in delivered], [g.sequence]) + + promoted = sender.receive(receiver.create_ack_for_received(g.sequence), 1001).ready_to_send + self.assertEqual(promoted[0].sequence, g.sequence + 1024) + + def test_cultnet_rudp_a_direct_send_past_the_span_is_refused_without_consuming_a_sequence(self) -> None: + sender = self._connected_flow_session(1) + g = self._lose_g_and_fill_the_span(sender) + with self.assertRaises(ValueError): + sender.send("state", bytes(1), CultNetRudpSendOptions(reliable=True)) + self.assertEqual(sender.queued_reliable_packet_count, 0) + sender.receive(self._flow_ack(g.sequence), 2) + following = sender.send("state", bytes(1), CultNetRudpSendOptions(reliable=True)) + self.assertEqual(following.sequence, g.sequence + 1024) + + def test_cultnet_rudp_payload_above_the_lowest_unacked_sequence_is_bounded_by_4_mib(self) -> None: + sender = self._connected_flow_session(1) + g = self._flow_send(sender, self.MIB)[0] + # The lowest unacked packet's own bytes do not count: four more MiB fit above it. + for _ in range(4): + self.assertEqual(len(self._flow_send(sender, self.MIB)), 1) + self.assertEqual(len(self._flow_send(sender, self.MIB)), 0) + self.assertEqual(len(self._flow_send(sender, self.MIB)), 0) + self.assertEqual(sender.queued_reliable_packet_count, 2) + + # Acking g moves the floor up one packet: exactly one MiB more fits. + promoted = sender.receive(self._flow_ack(g.sequence), 1).ready_to_send + self.assertEqual([p.sequence for p in promoted], [g.sequence + 5]) + self.assertEqual(sender.queued_reliable_packet_count, 1) + + def test_cultnet_rudp_a_packet_larger_than_the_window_goes_out_alone_and_waits_behind_anything_unacked(self) -> None: + self.assertEqual(len(self._flow_send(self._connected_flow_session(1), 5 * self.MIB)), 1) + sender = self._connected_flow_session(1) + g = self._flow_send(sender, 1)[0] + self.assertEqual(len(self._flow_send(sender, 5 * self.MIB)), 0) + promoted = sender.receive(self._flow_ack(g.sequence), 1).ready_to_send + self.assertEqual([p.sequence for p in promoted], [g.sequence + 1]) + + def test_cultnet_rudp_a_small_packet_does_not_overtake_a_queued_large_one(self) -> None: + sender = self._connected_flow_session(1) + g = self._flow_send(sender, 1)[0] + self.assertEqual(len(self._flow_send(sender, 3 * self.MIB)), 1) + self.assertEqual(len(self._flow_send(sender, 2 * self.MIB)), 0) + # One byte would fit above g, but the 2 MiB packet is ahead of it. + self.assertEqual(len(self._flow_send(sender, 1)), 0) + promoted = sender.receive(self._flow_ack(g.sequence), 1).ready_to_send + self.assertEqual([p.sequence for p in promoted], [g.sequence + 2, g.sequence + 3]) + def test_cultnet_rudp_socket_transport_handshakes_and_carries_reliable_ordered_schema_frames(self) -> None: server_socket = bind_udp_socket() client_socket = bind_udp_socket() diff --git a/packages/cultnet-rs/tests/rudp_flow_window.rs b/packages/cultnet-rs/tests/rudp_flow_window.rs new file mode 100644 index 00000000..b288de44 --- /dev/null +++ b/packages/cultnet-rs/tests/rudp_flow_window.rs @@ -0,0 +1,172 @@ +//! The sender's flow window: a reliable packet goes on the wire only while its +//! sequence is at most 1,023 above the lowest unacked one and the payload above +//! that sequence stays within 4 MiB. Everything else waits in order in the queue. + +use anyhow::Result; +use cultnet_rs::*; + +const CONNECTION_ID: u32 = 0x464c_4f57; +const MIB: usize = 1024 * 1024; + +fn connected(initial_sequence: u32) -> Result { + let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: CONNECTION_ID, + initial_sequence, + resend_delay_ms: 25, + max_pending_reliable_packets: None, + }); + session.receive( + &CultNetRudpPacket { + packet_type: CultNetRudpPacketType::Accept, + connection_id: CONNECTION_ID, + sequence: 0, + ack: 0, + ack_mask: 0, + channel_id: "control".into(), + reliable: false, + ordered: false, + sequenced: false, + fragment_id: 0, + fragment_index: 0, + fragment_count: 0, + payload: Vec::new(), + }, + 0, + )?; + Ok(session) +} + +fn reliable() -> CultNetRudpSendOptions { + CultNetRudpSendOptions { + reliable: true, + ..Default::default() + } +} + +/// What `send_many` puts on the wire now; the rest of the packet waits queued. +fn send(session: &mut CultNetRudpSession, payload_bytes: usize) -> Result> { + session.send_many("state", vec![7; payload_bytes], reliable(), None) +} + +fn ack_for(sequence: u32) -> CultNetRudpPacket { + CultNetRudpPacket { + packet_type: CultNetRudpPacketType::Ack, + connection_id: CONNECTION_ID, + sequence: 0, + ack: sequence, + ack_mask: 0, + channel_id: "control".into(), + reliable: false, + ordered: false, + sequenced: false, + fragment_id: 0, + fragment_index: 0, + fragment_count: 0, + payload: Vec::new(), + } +} + +fn sequences(packets: &[CultNetRudpPacket]) -> Vec { + packets.iter().map(|packet| packet.sequence).collect() +} + +/// Sends `g`, then acks every packet up to and including the one 1,023 above +/// it, leaving `g` the only unacked sequence. Returns `g`. +fn lose_g_and_fill_the_span(session: &mut CultNetRudpSession) -> Result { + let g = send(session, 1)?.remove(0); + for offset in 1..=1_023u32 { + let sent = send(session, 1)?; + assert_eq!(sequences(&sent), vec![g.sequence + offset]); + session.receive(&ack_for(g.sequence + offset), 1)?; + } + Ok(g) +} + +#[test] +fn a_lost_packet_holds_the_sender_1023_sequences_ahead_and_is_still_delivered() -> Result<()> { + let mut sender = connected(1)?; + let mut receiver = connected(100)?; + let g = send(&mut sender, 1)?.remove(0); + // g is lost. Everything after it is delivered and acked, until the sender stops. + let mut admitted_after_g = 0u32; + for _ in 0..4_200 { + for packet in send(&mut sender, 1)? { + admitted_after_g += 1; + receiver.receive(&packet, 1)?; + sender.receive(&receiver.create_ack(), 1)?; + } + } + assert_eq!(admitted_after_g, 1_023); + assert_eq!(sender.queued_reliable_packet_count(), 4_200 - 1_023); + + let retransmit = sender + .due_resends(1_000) + .into_iter() + .find(|packet| packet.sequence == g.sequence) + .expect("g is still pending and due"); + let delivered = receiver.receive(&retransmit, 1_000)?.delivered; + assert_eq!(delivered.len(), 1); + assert_eq!(delivered[0].sequence, g.sequence); + + let promoted = sender + .receive(&receiver.create_ack_for_received(g.sequence), 1_001)? + .ready_to_send; + assert_eq!(promoted.first().map(|packet| packet.sequence), Some(g.sequence + 1_024)); + Ok(()) +} + +#[test] +fn a_direct_send_past_the_span_is_refused_without_consuming_a_sequence() -> Result<()> { + let mut sender = connected(1)?; + let g = lose_g_and_fill_the_span(&mut sender)?; + assert!(sender.send("state", vec![7], reliable()).is_err()); + assert_eq!(sender.queued_reliable_packet_count(), 0); + sender.receive(&ack_for(g.sequence), 2)?; + let next = sender.send("state", vec![7], reliable())?; + assert_eq!(next.sequence, g.sequence + 1_024); + Ok(()) +} + +#[test] +fn payload_above_the_lowest_unacked_sequence_is_bounded_by_4_mib() -> Result<()> { + let mut sender = connected(1)?; + let g = send(&mut sender, MIB)?.remove(0); + // The lowest unacked packet's own bytes do not count: four more MiB fit above it. + for _ in 0..4 { + assert_eq!(send(&mut sender, MIB)?.len(), 1); + } + assert!(send(&mut sender, MIB)?.is_empty()); + assert!(send(&mut sender, MIB)?.is_empty()); + assert_eq!(sender.queued_reliable_packet_count(), 2); + + // Acking g moves the floor up one packet: exactly one MiB more fits. + let promoted = sender.receive(&ack_for(g.sequence), 1)?.ready_to_send; + assert_eq!(sequences(&promoted), vec![g.sequence + 5]); + assert_eq!(sender.queued_reliable_packet_count(), 1); + Ok(()) +} + +#[test] +fn a_packet_larger_than_the_window_goes_out_alone_and_waits_behind_anything_unacked() -> Result<()> { + let mut sender = connected(1)?; + assert_eq!(send(&mut sender, 5 * MIB)?.len(), 1); + let mut sender = connected(1)?; + let g = send(&mut sender, 1)?.remove(0); + assert!(send(&mut sender, 5 * MIB)?.is_empty()); + let promoted = sender.receive(&ack_for(g.sequence), 1)?.ready_to_send; + assert_eq!(sequences(&promoted), vec![g.sequence + 1]); + Ok(()) +} + +#[test] +fn a_small_packet_does_not_overtake_a_queued_large_one() -> Result<()> { + let mut sender = connected(1)?; + let g = send(&mut sender, 1)?.remove(0); + assert_eq!(send(&mut sender, 3 * MIB)?.len(), 1); + assert!(send(&mut sender, 2 * MIB)?.is_empty()); + // One byte would fit above g, but the 2 MiB packet is ahead of it. + assert!(send(&mut sender, 1)?.is_empty()); + let promoted = sender.receive(&ack_for(g.sequence), 1)?.ready_to_send; + assert_eq!(sequences(&promoted), vec![g.sequence + 2, g.sequence + 3]); + Ok(()) +} diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index 35cba767..6751b568 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -1413,6 +1413,101 @@ test("rudp session advances large fragment sets through a bounded reliable windo assert.equal(oldAck.ackMask, 0); }); +// The sender's flow window: a reliable packet goes on the wire only while its sequence is at most 1,023 above the +// lowest unacked one and the payload above that sequence stays within 4 MiB. +const FLOW_CONNECTION_ID = 0x464c4f57; +const MIB = 1024 * 1024; + +function connectedFlowSession(initialSequence: number): CultNetRudpSession { + const session = new CultNetRudpSession({ connectionId: FLOW_CONNECTION_ID, initialSequence, resendDelayMs: 25 }); + session.receive({ packetType: "accept", connectionId: FLOW_CONNECTION_ID, sequence: 0, ack: 0, ackMask: 0, channelId: "control" }); + return session; +} + +function flowSend(session: CultNetRudpSession, payloadBytes: number): CultNetRudpPacket[] { + return session.sendMany("state", new Uint8Array(payloadBytes), { reliable: true }); +} + +function flowAck(sequence: number): CultNetRudpPacket { + return { packetType: "ack", connectionId: FLOW_CONNECTION_ID, sequence: 0, ack: sequence, ackMask: 0, channelId: "control" }; +} + +const sequencesOf = (packets: CultNetRudpPacket[]): number[] => packets.map((packet) => packet.sequence); + +function loseGAndFillTheSpan(session: CultNetRudpSession): CultNetRudpPacket { + const g = flowSend(session, 1)[0]!; + for (let offset = 1; offset <= 1023; offset++) { + assert.deepEqual(sequencesOf(flowSend(session, 1)), [g.sequence + offset]); + session.receive(flowAck(g.sequence + offset), 1); + } + return g; +} + +test("a lost packet holds the sender 1023 sequences ahead and is still delivered", () => { + const sender = connectedFlowSession(1); + const receiver = connectedFlowSession(100); + const g = flowSend(sender, 1)[0]!; + let admittedAfterG = 0; + for (let index = 0; index < 4200; index++) { + for (const packet of flowSend(sender, 1)) { + admittedAfterG++; + receiver.receive(packet, 1); + sender.receive(receiver.createAck(), 1); + } + } + assert.equal(admittedAfterG, 1023); + assert.equal(sender.queuedReliablePacketCount, 4200 - 1023); + + const retransmit = sender.dueResends(1000).find((packet) => packet.sequence === g.sequence)!; + const delivered = receiver.receive(retransmit, 1000).delivered; + assert.deepEqual(delivered.map((frame) => frame.sequence), [g.sequence]); + + const promoted = sender.receive(receiver.createAckForReceived(g.sequence), 1001).readyToSend; + assert.equal(promoted[0]!.sequence, g.sequence + 1024); +}); + +test("a direct send past the span is refused without consuming a sequence", () => { + const sender = connectedFlowSession(1); + const g = loseGAndFillTheSpan(sender); + assert.throws(() => sender.send("state", new Uint8Array(1), { reliable: true })); + assert.equal(sender.queuedReliablePacketCount, 0); + sender.receive(flowAck(g.sequence), 2); + assert.equal(sender.send("state", new Uint8Array(1), { reliable: true }).sequence, g.sequence + 1024); +}); + +test("payload above the lowest unacked sequence is bounded by 4 MiB", () => { + const sender = connectedFlowSession(1); + const g = flowSend(sender, MIB)[0]!; + // The lowest unacked packet's own bytes do not count: four more MiB fit above it. + for (let index = 0; index < 4; index++) assert.equal(flowSend(sender, MIB).length, 1); + assert.equal(flowSend(sender, MIB).length, 0); + assert.equal(flowSend(sender, MIB).length, 0); + assert.equal(sender.queuedReliablePacketCount, 2); + + // Acking g moves the floor up one packet: exactly one MiB more fits. + const promoted = sender.receive(flowAck(g.sequence), 1).readyToSend; + assert.deepEqual(sequencesOf(promoted), [g.sequence + 5]); + assert.equal(sender.queuedReliablePacketCount, 1); +}); + +test("a packet larger than the window goes out alone and waits behind anything unacked", () => { + assert.equal(flowSend(connectedFlowSession(1), 5 * MIB).length, 1); + const sender = connectedFlowSession(1); + const g = flowSend(sender, 1)[0]!; + assert.equal(flowSend(sender, 5 * MIB).length, 0); + assert.deepEqual(sequencesOf(sender.receive(flowAck(g.sequence), 1).readyToSend), [g.sequence + 1]); +}); + +test("a small packet does not overtake a queued large one", () => { + const sender = connectedFlowSession(1); + const g = flowSend(sender, 1)[0]!; + assert.equal(flowSend(sender, 3 * MIB).length, 1); + assert.equal(flowSend(sender, 2 * MIB).length, 0); + // One byte would fit above g, but the 2 MiB packet is ahead of it. + assert.equal(flowSend(sender, 1).length, 0); + assert.deepEqual(sequencesOf(sender.receive(flowAck(g.sequence), 1).readyToSend), [g.sequence + 2, g.sequence + 3]); +}); + test("CultNet contracts encode legacy bytes without Node Buffer authority", () => { const originalBuffer = globalThis.Buffer; try { diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index e32ca91a..b9bda3ad 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1160,6 +1160,120 @@ namespace GameCult.Networking.Tests Assert.That(delivered[0].Payload, Is.EqualTo(payload)); } + // The sender's flow window: a reliable packet goes on the wire only while its sequence is at most 1,023 + // above the lowest unacked one and the payload above that sequence stays within 4 MiB. + private const int Mib = 1024 * 1024; + + private static CultNetRudpSession ConnectedFlowSession(uint initialSequence) + { + var session = new CultNetRudpSession(new CultNetRudpSessionOptions + { + ConnectionId = 0x464c4f57, + InitialSequence = initialSequence, + ResendDelayMs = 25 + }); + session.Receive(new CultNetRudpPacket { PacketType = CultNetRudpPacketType.Accept, ConnectionId = 0x464c4f57, Sequence = 0, ChannelId = "control" }); + return session; + } + + private static IReadOnlyList FlowSend(CultNetRudpSession session, int payloadBytes) => + session.SendMany("state", new byte[payloadBytes], new CultNetRudpSendOptions { Reliable = true }); + + private static CultNetRudpPacket FlowAck(uint sequence) => + new CultNetRudpPacket { PacketType = CultNetRudpPacketType.Ack, ConnectionId = 0x464c4f57, Ack = sequence, ChannelId = "control" }; + + private static CultNetRudpPacket LoseGAndFillTheSpan(CultNetRudpSession session) + { + var g = FlowSend(session, 1).Single(); + for (uint offset = 1; offset <= 1023; offset++) + { + Assert.That(FlowSend(session, 1).Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + offset })); + session.Receive(FlowAck(g.Sequence + offset), 1); + } + return g; + } + + [Test] + public void RudpSession_ALostPacketHoldsTheSender1023SequencesAheadAndIsStillDelivered() + { + var sender = ConnectedFlowSession(1); + var receiver = ConnectedFlowSession(100); + var g = FlowSend(sender, 1).Single(); + var admittedAfterG = 0; + for (var index = 0; index < 4200; index++) + { + foreach (var packet in FlowSend(sender, 1)) + { + admittedAfterG++; + receiver.Receive(packet, 1); + sender.Receive(receiver.CreateAck(), 1); + } + } + Assert.That(admittedAfterG, Is.EqualTo(1023)); + Assert.That(sender.QueuedReliablePacketCount, Is.EqualTo(4200 - 1023)); + + var retransmit = sender.DueResends(1000).Single(packet => packet.Sequence == g.Sequence); + var delivered = receiver.Receive(retransmit, 1000).Delivered; + Assert.That(delivered.Select(frame => frame.Sequence), Is.EqualTo(new[] { g.Sequence })); + + var promoted = sender.Receive(receiver.CreateAckForReceived(g.Sequence), 1001).ReadyToSend; + Assert.That(promoted.First().Sequence, Is.EqualTo(g.Sequence + 1024)); + } + + [Test] + public void RudpSession_ADirectSendPastTheSpanIsRefusedWithoutConsumingASequence() + { + var sender = ConnectedFlowSession(1); + var g = LoseGAndFillTheSpan(sender); + Assert.Throws(() => sender.Send("state", new byte[1], new CultNetRudpSendOptions { Reliable = true })); + Assert.That(sender.QueuedReliablePacketCount, Is.Zero); + sender.Receive(FlowAck(g.Sequence), 2); + var next = sender.Send("state", new byte[1], new CultNetRudpSendOptions { Reliable = true }); + Assert.That(next.Sequence, Is.EqualTo(g.Sequence + 1024)); + } + + [Test] + public void RudpSession_PayloadAboveTheLowestUnackedSequenceIsBoundedBy4MiB() + { + var sender = ConnectedFlowSession(1); + var g = FlowSend(sender, Mib).Single(); + // The lowest unacked packet's own bytes do not count: four more MiB fit above it. + for (var index = 0; index < 4; index++) + Assert.That(FlowSend(sender, Mib), Has.Count.EqualTo(1)); + Assert.That(FlowSend(sender, Mib), Is.Empty); + Assert.That(FlowSend(sender, Mib), Is.Empty); + Assert.That(sender.QueuedReliablePacketCount, Is.EqualTo(2)); + + // Acking g moves the floor up one packet: exactly one MiB more fits. + var promoted = sender.Receive(FlowAck(g.Sequence), 1).ReadyToSend; + Assert.That(promoted.Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 5 })); + Assert.That(sender.QueuedReliablePacketCount, Is.EqualTo(1)); + } + + [Test] + public void RudpSession_APacketLargerThanTheWindowGoesOutAloneAndWaitsBehindAnythingUnacked() + { + Assert.That(FlowSend(ConnectedFlowSession(1), 5 * Mib), Has.Count.EqualTo(1)); + var sender = ConnectedFlowSession(1); + var g = FlowSend(sender, 1).Single(); + Assert.That(FlowSend(sender, 5 * Mib), Is.Empty); + var promoted = sender.Receive(FlowAck(g.Sequence), 1).ReadyToSend; + Assert.That(promoted.Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 1 })); + } + + [Test] + public void RudpSession_ASmallPacketDoesNotOvertakeAQueuedLargeOne() + { + var sender = ConnectedFlowSession(1); + var g = FlowSend(sender, 1).Single(); + Assert.That(FlowSend(sender, 3 * Mib), Has.Count.EqualTo(1)); + Assert.That(FlowSend(sender, 2 * Mib), Is.Empty); + // One byte would fit above g, but the 2 MiB packet is ahead of it. + Assert.That(FlowSend(sender, 1), Is.Empty); + var promoted = sender.Receive(FlowAck(g.Sequence), 1).ReadyToSend; + Assert.That(promoted.Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 2, g.Sequence + 3 })); + } + [Test] public void RudpSocketTransport_HandshakesAndCarriesReliableOrderedSchemaFrames() { From eeda6d7fd61984594059c52d67399bc861faf243 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:55:14 +0200 Subject: [PATCH 02/11] Ack Cut 3: the sender's flow window (1,023 sequences and 4 MiB above the lowest unacked) Admission in Rust, C#, TypeScript and Python goes through one predicate, window_admits: the 32-packet slot, a sequence at most 1,023 above the lowest unacked one, and at most 4 MiB of payload above it. With nothing pending any packet is admissible. Admission is FIFO: nothing overtakes a queued packet. The single-packet send refuses on the same predicate instead of queueing a packet and then reporting that it produced none. The receive window is stated in docs/cultnet-transport-parity.md. Kotlin trails (Cut K3). Co-Authored-By: Claude Sonnet 5.5 --- docs/cultnet-transport-parity.md | 9 ++++ .../cultnet-py/src/cultnet_py/transport.py | 39 +++++++++++---- packages/cultnet-rs/src/rudp.rs | 48 +++++++++++++++---- packages/cultnet-ts/src/rudp.ts | 45 +++++++++++++---- src/GameCult.Networking/CultNetTransport.cs | 38 ++++++++++++--- 5 files changed, 146 insertions(+), 33 deletions(-) diff --git a/docs/cultnet-transport-parity.md b/docs/cultnet-transport-parity.md index d0779196..17fc6964 100644 --- a/docs/cultnet-transport-parity.md +++ b/docs/cultnet-transport-parity.md @@ -271,6 +271,15 @@ Current progress: flush where the runtime supports one, so callers that require confirmed transport delivery do not substitute a fixed sleep. TCP-framed profiles do not publish the field because they do not own the RUDP pending-reliable queue. +- The receive window is a contract, not a discovery. A receiver must hold 1,023 + sequences and 4 MiB of payload beyond its contiguous received watermark, and + a conforming sender never exceeds either. The sender's flow window enforces + it without a wire field: a reliable packet is admitted only while its + sequence is at most 1,023 above the lowest unacked sequence and the payload + above that sequence stays within 4 MiB. The first packet is always + admissible, so a payload larger than 4 MiB goes out alone, and packets wait + in the FIFO in order, so a small packet never overtakes a large one. C#, + Rust, TypeScript, and Python enforce it; Kotlin does not yet. - TypeScript, C#, Rust, Python, and Kotlin now share a portable `cultnet.reconnect_policy.v0` document and deterministic exponential delay helper, and RUDP transport profiles advertise that policy under diff --git a/packages/cultnet-py/src/cultnet_py/transport.py b/packages/cultnet-py/src/cultnet_py/transport.py index 0f378613..6b0ce026 100644 --- a/packages/cultnet-py/src/cultnet_py/transport.py +++ b/packages/cultnet-py/src/cultnet_py/transport.py @@ -402,6 +402,10 @@ def create_rudp_transport_profile( class CultNetRudpSession: RELIABLE_SEND_WINDOW_PACKETS = 32 RECEIVED_SEQUENCE_WINDOW = 4_096 + # Flow window: a reliable packet is admitted only while its sequence is at most FLOW_WINDOW_SEQUENCES above the + # lowest unacked one and the payload above that sequence stays within FLOW_WINDOW_BYTES. + FLOW_WINDOW_SEQUENCES = 1_023 + FLOW_WINDOW_BYTES = 4 * 1024 * 1024 def __init__(self, options: CultNetRudpSessionOptions) -> None: self.connection_id = _uint32(options.connection_id, "connection_id") @@ -531,7 +535,7 @@ class CultNetRudpSession: options: CultNetRudpSendOptions | None = None, ) -> CultNetRudpPacket: resolved = options or CultNetRudpSendOptions() - if resolved.reliable and len(self._pending_reliable) >= self.RELIABLE_SEND_WINDOW_PACKETS: + if resolved.reliable and (self._queued_reliable or not self._window_admits(self._next_sequence, len(payload))): raise ValueError("RUDP reliable send window is full; receive acknowledgements before sending") return self.send_many(channel_id, payload, resolved)[0] @@ -773,22 +777,39 @@ class CultNetRudpSession: last_sent_at_ms=now_ms, ) + def _window_admits(self, sequence: int, payload_length: int) -> bool: + """Whether a reliable packet may go on the wire now: the window has a slot, and its sequence and the + payload above the lowest unacked sequence stay inside the flow window. With nothing pending, any packet + is admissible.""" + if len(self._pending_reliable) >= self.RELIABLE_SEND_WINDOW_PACKETS: + return False + if not self._pending_reliable: + return True + lowest = min(self._pending_reliable) + bytes_above = sum( + len(pending.packet.payload) for pending_sequence, pending in self._pending_reliable.items() if pending_sequence > lowest + ) + return sequence - lowest <= self.FLOW_WINDOW_SEQUENCES and bytes_above + payload_length <= self.FLOW_WINDOW_BYTES + def _admit_reliable_packets( self, packets: tuple[CultNetRudpPacket, ...], now_ms: int, ) -> tuple[CultNetRudpPacket, ...]: - available = max(0, self.RELIABLE_SEND_WINDOW_PACKETS - len(self._pending_reliable)) - ready = packets[:available] - for packet in ready: - self._track_reliable(packet, now_ms) - self._queued_reliable.extend(packets[available:]) - return ready + ready: list[CultNetRudpPacket] = [] + for packet in packets: + if not self._queued_reliable and self._window_admits(packet.sequence, len(packet.payload)): + self._track_reliable(packet, now_ms) + ready.append(packet) + else: + self._queued_reliable.append(packet) + return tuple(ready) def _promote_queued_reliable(self, now_ms: int) -> tuple[CultNetRudpPacket, ...]: - available = max(0, self.RELIABLE_SEND_WINDOW_PACKETS - len(self._pending_reliable)) ready: list[CultNetRudpPacket] = [] - while len(ready) < available and self._queued_reliable: + while self._queued_reliable and self._window_admits( + self._queued_reliable[0].sequence, len(self._queued_reliable[0].payload) + ): packet = self._queued_reliable.popleft() self._track_reliable(packet, now_ms) ready.append(packet) diff --git a/packages/cultnet-rs/src/rudp.rs b/packages/cultnet-rs/src/rudp.rs index 3b348fe3..ac4054a9 100644 --- a/packages/cultnet-rs/src/rudp.rs +++ b/packages/cultnet-rs/src/rudp.rs @@ -46,6 +46,13 @@ const RUDP_MAX_TRACKED_CHANNELS: usize = 64; const RUDP_RECEIVE_AHEAD_WINDOW: u32 = 1_024; const RUDP_MAX_ORDERED_BUFFERED_FRAMES: usize = 1_024; const RUDP_MAX_ORDERED_BUFFERED_BYTES: usize = 4 * 1024 * 1024; +/// The sender's flow window: a reliable packet goes on the wire only while its +/// sequence is at most this far above the lowest unacked one, and the payload +/// above that sequence stays within the byte bound. A receiver holds +/// `RUDP_RECEIVE_AHEAD_WINDOW - 1` sequences and `RUDP_MAX_ORDERED_BUFFERED_BYTES` +/// beyond its watermark, so a sender that obeys this never overruns it. +const RUDP_FLOW_WINDOW_SEQUENCES: u32 = RUDP_RECEIVE_AHEAD_WINDOW - 1; +const RUDP_FLOW_WINDOW_BYTES: usize = RUDP_MAX_ORDERED_BUFFERED_BYTES; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CultNetRudpPacketType { @@ -441,7 +448,8 @@ impl CultNetRudpSession { options: CultNetRudpSendOptions, ) -> Result { if options.reliable - && self.pending_reliable.len() >= CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + && (!self.queued_reliable.is_empty() + || !self.window_admits(self.next_sequence, payload.len())) { return Err(anyhow!( "RUDP reliable send window is full; receive acknowledgements before sending" @@ -907,17 +915,37 @@ impl CultNetRudpSession { .saturating_add((before - after) as u64); } + /// Whether a reliable packet may go on the wire now: the window has a slot, + /// and its sequence and the payload above the lowest unacked sequence stay + /// inside the flow window. With nothing pending, any packet is admissible. + fn window_admits(&self, sequence: u32, payload_len: usize) -> bool { + if self.pending_reliable.len() >= CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS { + return false; + } + let Some((&lowest, _)) = self.pending_reliable.first_key_value() else { + return true; + }; + let bytes_above: usize = self + .pending_reliable + .iter() + .filter(|(pending_sequence, _)| **pending_sequence > lowest) + .map(|(_, pending)| pending.packet.payload.len()) + .sum(); + sequence.saturating_sub(lowest) <= RUDP_FLOW_WINDOW_SEQUENCES + && bytes_above + payload_len <= RUDP_FLOW_WINDOW_BYTES + } + fn admit_reliable_packets( &mut self, packets: Vec, now_ms: u64, expires_at_ms: Option, ) -> Vec { - let available = - CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS.saturating_sub(self.pending_reliable.len()); - let mut ready = Vec::with_capacity(available.min(packets.len())); + let mut ready = Vec::new(); for packet in packets { - if ready.len() < available { + if self.queued_reliable.is_empty() + && self.window_admits(packet.sequence, packet.payload.len()) + { self.track_reliable(packet.clone(), now_ms, expires_at_ms); ready.push(packet); } else { @@ -928,10 +956,12 @@ impl CultNetRudpSession { } fn promote_queued_reliable(&mut self, now_ms: u64) -> Vec { - let available = - CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS.saturating_sub(self.pending_reliable.len()); - let mut ready = Vec::with_capacity(available.min(self.queued_reliable.len())); - for _ in 0..available { + let mut ready = Vec::new(); + while self + .queued_reliable + .front() + .is_some_and(|(packet, _)| self.window_admits(packet.sequence, packet.payload.len())) + { let Some((packet, expires_at_ms)) = self.queued_reliable.pop_front() else { break; }; diff --git a/packages/cultnet-ts/src/rudp.ts b/packages/cultnet-ts/src/rudp.ts index ae7ea838..10a6b9a2 100644 --- a/packages/cultnet-ts/src/rudp.ts +++ b/packages/cultnet-ts/src/rudp.ts @@ -18,6 +18,10 @@ const RUDP_FIXED_HEADER_BYTES = 36; const MAX_CHANNEL_ID_BYTES = 255; export const CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS = 32; const RUDP_RECEIVED_SEQUENCE_WINDOW = 4_096; +// Flow window: a reliable packet is admitted only while its sequence is at most RUDP_FLOW_WINDOW_SEQUENCES above the +// lowest unacked one and the payload above that sequence stays within RUDP_FLOW_WINDOW_BYTES. +const RUDP_FLOW_WINDOW_SEQUENCES = 1_023; +const RUDP_FLOW_WINDOW_BYTES = 4 * 1024 * 1024; export type CultNetRudpPacketType = | "connect" @@ -295,7 +299,7 @@ export class CultNetRudpSession { payload: Uint8Array, options: { reliable?: boolean; ordered?: boolean; sequenced?: boolean; nowMs?: number } = {}, ): CultNetRudpPacket { - if (options.reliable && this.#pendingReliable.size >= CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS) { + if (options.reliable && (this.#queuedReliable.length > 0 || !this.#windowAdmits(this.#nextSequence, payload.length))) { throw new Error("RUDP reliable send window is full; receive acknowledgements before sending."); } return this.sendMany(channelId, payload, options)[0]!; @@ -583,18 +587,43 @@ export class CultNetRudpSession { }); } + /** + * Whether a reliable packet may go on the wire now: the window has a slot, and its sequence and the payload above + * the lowest unacked sequence stay inside the flow window. With nothing pending, any packet is admissible. + */ + #windowAdmits(sequence: number, payloadLength: number): boolean { + if (this.#pendingReliable.size >= CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS) return false; + if (this.#pendingReliable.size === 0) return true; + const lowest = Math.min(...this.#pendingReliable.keys()); + let bytesAbove = 0; + for (const [pendingSequence, pending] of this.#pendingReliable) { + if (pendingSequence > lowest) bytesAbove += pending.packet.payload?.length ?? 0; + } + return sequence - lowest <= RUDP_FLOW_WINDOW_SEQUENCES && bytesAbove + payloadLength <= RUDP_FLOW_WINDOW_BYTES; + } + #admitReliablePackets(packets: CultNetRudpPacket[], nowMs: number): CultNetRudpPacket[] { - const available = Math.max(0, CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS - this.#pendingReliable.size); - const ready = packets.slice(0, available); - for (const packet of ready) this.#trackReliable(packet, nowMs); - this.#queuedReliable.push(...packets.slice(available)); + const ready: CultNetRudpPacket[] = []; + for (const packet of packets) { + if (this.#queuedReliable.length === 0 && this.#windowAdmits(packet.sequence, packet.payload?.length ?? 0)) { + this.#trackReliable(packet, nowMs); + ready.push(packet); + } else { + this.#queuedReliable.push(packet); + } + } return ready; } #promoteQueuedReliable(nowMs: number): CultNetRudpPacket[] { - const available = Math.max(0, CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS - this.#pendingReliable.size); - const ready = this.#queuedReliable.splice(0, available); - for (const packet of ready) this.#trackReliable(packet, nowMs); + const ready: CultNetRudpPacket[] = []; + while (this.#queuedReliable.length > 0) { + const packet = this.#queuedReliable[0]; + if (!this.#windowAdmits(packet.sequence, packet.payload?.length ?? 0)) break; + this.#queuedReliable.shift(); + this.#trackReliable(packet, nowMs); + ready.push(packet); + } return ready; } diff --git a/src/GameCult.Networking/CultNetTransport.cs b/src/GameCult.Networking/CultNetTransport.cs index e98fdc03..de61d1af 100644 --- a/src/GameCult.Networking/CultNetTransport.cs +++ b/src/GameCult.Networking/CultNetTransport.cs @@ -881,6 +881,10 @@ namespace GameCult.Networking { /// Maximum reliable packets admitted to the wire before acknowledgements advance the window. public const int ReliableSendWindowPackets = 32; + // Flow window: a reliable packet is admitted only while its sequence is at most FlowWindowSequences above the + // lowest unacked one and the payload above that sequence stays within FlowWindowBytes. + private const uint FlowWindowSequences = 1023; + private const int FlowWindowBytes = 4 * 1024 * 1024; private const int ReceivedSequenceWindow = 4096; private sealed class PendingReliablePacket { @@ -1063,7 +1067,7 @@ namespace GameCult.Networking options ??= new CultNetRudpSendOptions(); lock (_pendingReliableGate) { - if (options.Reliable && _pendingReliable.Count >= ReliableSendWindowPackets) + if (options.Reliable && (_queuedReliable.Count > 0 || !WindowAdmits(_nextSequence, payload.Length))) throw new InvalidOperationException("RUDP reliable send window is full; receive acknowledgements before sending."); } return SendMany(channelId, payload, options).First(); @@ -1436,17 +1440,37 @@ namespace GameCult.Networking } } + /// + /// Whether a reliable packet may go on the wire now: the window has a slot, and its sequence and the + /// payload above the lowest unacked sequence stay inside the flow window. With nothing pending, any + /// packet is admissible. Callers hold the pending gate. + /// + private bool WindowAdmits(uint sequence, int payloadLength) + { + if (_pendingReliable.Count >= ReliableSendWindowPackets) + return false; + if (_pendingReliable.Count == 0) + return true; + var lowest = _pendingReliable.Keys.Min(); + long bytesAbove = 0; + foreach (var pending in _pendingReliable) + { + if (pending.Key > lowest) + bytesAbove += pending.Value.Packet.Payload.Length; + } + return sequence - lowest <= FlowWindowSequences && bytesAbove + payloadLength <= FlowWindowBytes; + } + private IReadOnlyList AdmitReliablePackets( IReadOnlyList packets, long nowMs) { lock (_pendingReliableGate) { - var available = Math.Max(0, ReliableSendWindowPackets - _pendingReliable.Count); - var ready = new List(Math.Min(available, packets.Count)); + var ready = new List(); foreach (var packet in packets) { - if (ready.Count < available) + if (_queuedReliable.Count == 0 && WindowAdmits(packet.Sequence, packet.Payload.Length)) { _pendingReliable[packet.Sequence] = new PendingReliablePacket { @@ -1468,9 +1492,9 @@ namespace GameCult.Networking { lock (_pendingReliableGate) { - var available = Math.Max(0, ReliableSendWindowPackets - _pendingReliable.Count); - var ready = new List(Math.Min(available, _queuedReliable.Count)); - while (ready.Count < available && _queuedReliable.Count > 0) + var ready = new List(); + while (_queuedReliable.Count > 0 + && WindowAdmits(_queuedReliable.Peek().Sequence, _queuedReliable.Peek().Payload.Length)) { var packet = _queuedReliable.Dequeue(); _pendingReliable[packet.Sequence] = new PendingReliablePacket From 4f13eb6689ce4d97644af28e0cc97e23df5f376f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:57:21 +0200 Subject: [PATCH 03/11] Ack Cut 3: a direct send behind a queued packet is refused and takes no sequence The FIFO test now also pins the single-packet send's refusal while an earlier packet waits in the queue, in Rust, C#, TypeScript and Python. Co-Authored-By: Claude Sonnet 5.5 --- packages/cultnet-py/tests/test_cultnet.py | 4 ++++ packages/cultnet-rs/tests/rudp_flow_window.rs | 3 +++ packages/cultnet-ts/test/cultnet.test.ts | 3 +++ tests/GameCult.Networking.Tests/NetworkingTests.cs | 3 +++ 4 files changed, 13 insertions(+) diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index 2161be04..e1205fef 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -963,6 +963,10 @@ class CultNetTests(unittest.TestCase): g = self._flow_send(sender, 1)[0] self.assertEqual(len(self._flow_send(sender, 3 * self.MIB)), 1) self.assertEqual(len(self._flow_send(sender, 2 * self.MIB)), 0) + # A direct send refuses instead of queueing behind it, and takes no sequence. + with self.assertRaises(ValueError): + sender.send("state", bytes(1), CultNetRudpSendOptions(reliable=True)) + self.assertEqual(sender.queued_reliable_packet_count, 1) # One byte would fit above g, but the 2 MiB packet is ahead of it. self.assertEqual(len(self._flow_send(sender, 1)), 0) promoted = sender.receive(self._flow_ack(g.sequence), 1).ready_to_send diff --git a/packages/cultnet-rs/tests/rudp_flow_window.rs b/packages/cultnet-rs/tests/rudp_flow_window.rs index b288de44..b052834c 100644 --- a/packages/cultnet-rs/tests/rudp_flow_window.rs +++ b/packages/cultnet-rs/tests/rudp_flow_window.rs @@ -164,6 +164,9 @@ fn a_small_packet_does_not_overtake_a_queued_large_one() -> Result<()> { let g = send(&mut sender, 1)?.remove(0); assert_eq!(send(&mut sender, 3 * MIB)?.len(), 1); assert!(send(&mut sender, 2 * MIB)?.is_empty()); + // A direct send refuses instead of queueing behind it, and takes no sequence. + assert!(sender.send("state", vec![7], reliable()).is_err()); + assert_eq!(sender.queued_reliable_packet_count(), 1); // One byte would fit above g, but the 2 MiB packet is ahead of it. assert!(send(&mut sender, 1)?.is_empty()); let promoted = sender.receive(&ack_for(g.sequence), 1)?.ready_to_send; diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index 6751b568..a3efa323 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -1503,6 +1503,9 @@ test("a small packet does not overtake a queued large one", () => { const g = flowSend(sender, 1)[0]!; assert.equal(flowSend(sender, 3 * MIB).length, 1); assert.equal(flowSend(sender, 2 * MIB).length, 0); + // A direct send refuses instead of queueing behind it, and takes no sequence. + assert.throws(() => sender.send("state", new Uint8Array(1), { reliable: true })); + assert.equal(sender.queuedReliablePacketCount, 1); // One byte would fit above g, but the 2 MiB packet is ahead of it. assert.equal(flowSend(sender, 1).length, 0); assert.deepEqual(sequencesOf(sender.receive(flowAck(g.sequence), 1).readyToSend), [g.sequence + 2, g.sequence + 3]); diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index b9bda3ad..7cdf256f 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1268,6 +1268,9 @@ namespace GameCult.Networking.Tests var g = FlowSend(sender, 1).Single(); Assert.That(FlowSend(sender, 3 * Mib), Has.Count.EqualTo(1)); Assert.That(FlowSend(sender, 2 * Mib), Is.Empty); + // A direct send refuses instead of queueing behind it, and takes no sequence. + Assert.Throws(() => sender.Send("state", new byte[1], new CultNetRudpSendOptions { Reliable = true })); + Assert.That(sender.QueuedReliablePacketCount, Is.EqualTo(1)); // One byte would fit above g, but the 2 MiB packet is ahead of it. Assert.That(FlowSend(sender, 1), Is.Empty); var promoted = sender.Receive(FlowAck(g.Sequence), 1).ReadyToSend; From a6dc231d40b3a7bcc930f9adeb6f8b8e6646de62 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 01:10:33 +0200 Subject: [PATCH 04/11] Ack Cut 3: TypeScript flow-window tests typecheck under noUncheckedIndexedAccess readyToSend is optional on the receive result; the tests read it as empty when absent. Co-Authored-By: Claude Sonnet 5.5 --- packages/cultnet-ts/test/cultnet.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index a3efa323..d2048f9d 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -1462,7 +1462,7 @@ test("a lost packet holds the sender 1023 sequences ahead and is still delivered const delivered = receiver.receive(retransmit, 1000).delivered; assert.deepEqual(delivered.map((frame) => frame.sequence), [g.sequence]); - const promoted = sender.receive(receiver.createAckForReceived(g.sequence), 1001).readyToSend; + const promoted = sender.receive(receiver.createAckForReceived(g.sequence), 1001).readyToSend ?? []; assert.equal(promoted[0]!.sequence, g.sequence + 1024); }); @@ -1485,7 +1485,7 @@ test("payload above the lowest unacked sequence is bounded by 4 MiB", () => { assert.equal(sender.queuedReliablePacketCount, 2); // Acking g moves the floor up one packet: exactly one MiB more fits. - const promoted = sender.receive(flowAck(g.sequence), 1).readyToSend; + const promoted = sender.receive(flowAck(g.sequence), 1).readyToSend ?? []; assert.deepEqual(sequencesOf(promoted), [g.sequence + 5]); assert.equal(sender.queuedReliablePacketCount, 1); }); @@ -1495,7 +1495,7 @@ test("a packet larger than the window goes out alone and waits behind anything u const sender = connectedFlowSession(1); const g = flowSend(sender, 1)[0]!; assert.equal(flowSend(sender, 5 * MIB).length, 0); - assert.deepEqual(sequencesOf(sender.receive(flowAck(g.sequence), 1).readyToSend), [g.sequence + 1]); + assert.deepEqual(sequencesOf(sender.receive(flowAck(g.sequence), 1).readyToSend ?? []), [g.sequence + 1]); }); test("a small packet does not overtake a queued large one", () => { @@ -1508,7 +1508,7 @@ test("a small packet does not overtake a queued large one", () => { assert.equal(sender.queuedReliablePacketCount, 1); // One byte would fit above g, but the 2 MiB packet is ahead of it. assert.equal(flowSend(sender, 1).length, 0); - assert.deepEqual(sequencesOf(sender.receive(flowAck(g.sequence), 1).readyToSend), [g.sequence + 2, g.sequence + 3]); + assert.deepEqual(sequencesOf(sender.receive(flowAck(g.sequence), 1).readyToSend ?? []), [g.sequence + 2, g.sequence + 3]); }); test("CultNet contracts encode legacy bytes without Node Buffer authority", () => { From ede87f7423818be14aec3f5b3d081ce1c8934690 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 10:57:53 +0200 Subject: [PATCH 05/11] Ack Cut 3: the flow-window tests shake hands the way Cut 1 requires An Accept now counts only while the session's own Connect awaits it, and reliable data is refused until the handshake seeds the watermark, so the tests' bare Accept no longer connected the session. The helpers now pair a sender and a receiver through CreateConnect/AcceptConnect in all four runtimes; the sender's Connect is acknowledged, so nothing is pending on it when the tests begin. Rust also takes initial_sequence as an Option. Co-Authored-By: Claude Sonnet 5.5 --- packages/cultnet-py/tests/test_cultnet.py | 22 ++++++--- packages/cultnet-rs/tests/rudp_flow_window.rs | 49 +++++++++---------- packages/cultnet-ts/test/cultnet.test.ts | 20 ++++++-- .../NetworkingTests.cs | 21 +++++--- 4 files changed, 69 insertions(+), 43 deletions(-) diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index 12f99f2e..8105c83d 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -882,12 +882,23 @@ class CultNetTests(unittest.TestCase): FLOW_CONNECTION_ID = 0x464C4F57 MIB = 1024 * 1024 - def _connected_flow_session(self, initial_sequence: int) -> CultNetRudpSession: - session = CultNetRudpSession( + def _flow_session(self, initial_sequence: int) -> CultNetRudpSession: + return CultNetRudpSession( CultNetRudpSessionOptions(connection_id=self.FLOW_CONNECTION_ID, initial_sequence=initial_sequence) ) - session.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, self.FLOW_CONNECTION_ID, 0, 0, 0, "control")) - return session + + def _connected_flow_pair( + self, sender_initial_sequence: int, receiver_initial_sequence: int + ) -> tuple[CultNetRudpSession, CultNetRudpSession]: + """A sender and a receiver that have shaken hands: the receiver's watermark is seeded by the sender's + Connect, and the sender's Connect is acknowledged, so nothing is pending on the sender.""" + sender = self._flow_session(sender_initial_sequence) + receiver = self._flow_session(receiver_initial_sequence) + sender.receive(receiver.accept_connect(sender.create_connect(0), 0), 0) + return sender, receiver + + def _connected_flow_session(self, initial_sequence: int) -> CultNetRudpSession: + return self._connected_flow_pair(initial_sequence, 900)[0] @staticmethod def _flow_send(session: CultNetRudpSession, payload_bytes: int) -> tuple[CultNetRudpPacket, ...]: @@ -904,8 +915,7 @@ class CultNetTests(unittest.TestCase): return g def test_cultnet_rudp_a_lost_packet_holds_the_sender_1023_sequences_ahead_and_is_still_delivered(self) -> None: - sender = self._connected_flow_session(1) - receiver = self._connected_flow_session(100) + sender, receiver = self._connected_flow_pair(1, 100) g = self._flow_send(sender, 1)[0] admitted_after_g = 0 for _ in range(4200): diff --git a/packages/cultnet-rs/tests/rudp_flow_window.rs b/packages/cultnet-rs/tests/rudp_flow_window.rs index b052834c..73b7f8e6 100644 --- a/packages/cultnet-rs/tests/rudp_flow_window.rs +++ b/packages/cultnet-rs/tests/rudp_flow_window.rs @@ -8,32 +8,32 @@ use cultnet_rs::*; const CONNECTION_ID: u32 = 0x464c_4f57; const MIB: usize = 1024 * 1024; -fn connected(initial_sequence: u32) -> Result { - let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions { +fn session(initial_sequence: u32) -> CultNetRudpSession { + CultNetRudpSession::new(CultNetRudpSessionOptions { connection_id: CONNECTION_ID, - initial_sequence, + initial_sequence: Some(initial_sequence), resend_delay_ms: 25, max_pending_reliable_packets: None, - }); - session.receive( - &CultNetRudpPacket { - packet_type: CultNetRudpPacketType::Accept, - connection_id: CONNECTION_ID, - sequence: 0, - ack: 0, - ack_mask: 0, - channel_id: "control".into(), - reliable: false, - ordered: false, - sequenced: false, - fragment_id: 0, - fragment_index: 0, - fragment_count: 0, - payload: Vec::new(), - }, - 0, - )?; - Ok(session) + }) +} + +/// A sender and a receiver that have shaken hands: the receiver's watermark is +/// seeded by the sender's Connect, and the sender's Connect is acknowledged, so +/// nothing is pending on the sender. +fn connected_pair( + sender_initial_sequence: u32, + receiver_initial_sequence: u32, +) -> Result<(CultNetRudpSession, CultNetRudpSession)> { + let mut sender = session(sender_initial_sequence); + let mut receiver = session(receiver_initial_sequence); + let connect = sender.create_connect(0, Vec::new())?; + let accept = receiver.accept_connect(&connect, 0, Vec::new())?; + sender.receive(&accept, 0)?; + Ok((sender, receiver)) +} + +fn connected(initial_sequence: u32) -> Result { + Ok(connected_pair(initial_sequence, 900)?.0) } fn reliable() -> CultNetRudpSendOptions { @@ -84,8 +84,7 @@ fn lose_g_and_fill_the_span(session: &mut CultNetRudpSession) -> Result Result<()> { - let mut sender = connected(1)?; - let mut receiver = connected(100)?; + let (mut sender, mut receiver) = connected_pair(1, 100)?; let g = send(&mut sender, 1)?.remove(0); // g is lost. Everything after it is delivered and acked, until the sender stops. let mut admitted_after_g = 0u32; diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index e671c2b6..f3c1612b 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -1421,10 +1421,21 @@ test("rudp session advances large fragment sets through a bounded reliable windo const FLOW_CONNECTION_ID = 0x464c4f57; const MIB = 1024 * 1024; +function flowSession(initialSequence: number): CultNetRudpSession { + return new CultNetRudpSession({ connectionId: FLOW_CONNECTION_ID, initialSequence, resendDelayMs: 25 }); +} + +// A sender and a receiver that have shaken hands: the receiver's watermark is seeded by the sender's Connect, and the +// sender's Connect is acknowledged, so nothing is pending on the sender. +function connectedFlowPair(senderInitialSequence: number, receiverInitialSequence: number): [CultNetRudpSession, CultNetRudpSession] { + const sender = flowSession(senderInitialSequence); + const receiver = flowSession(receiverInitialSequence); + sender.receive(receiver.acceptConnect(sender.createConnect(0), 0), 0); + return [sender, receiver]; +} + function connectedFlowSession(initialSequence: number): CultNetRudpSession { - const session = new CultNetRudpSession({ connectionId: FLOW_CONNECTION_ID, initialSequence, resendDelayMs: 25 }); - session.receive({ packetType: "accept", connectionId: FLOW_CONNECTION_ID, sequence: 0, ack: 0, ackMask: 0, channelId: "control" }); - return session; + return connectedFlowPair(initialSequence, 900)[0]; } function flowSend(session: CultNetRudpSession, payloadBytes: number): CultNetRudpPacket[] { @@ -1447,8 +1458,7 @@ function loseGAndFillTheSpan(session: CultNetRudpSession): CultNetRudpPacket { } test("a lost packet holds the sender 1023 sequences ahead and is still delivered", () => { - const sender = connectedFlowSession(1); - const receiver = connectedFlowSession(100); + const [sender, receiver] = connectedFlowPair(1, 100); const g = flowSend(sender, 1)[0]!; let admittedAfterG = 0; for (let index = 0; index < 4200; index++) { diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 470a20f2..15091ec3 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1145,18 +1145,26 @@ namespace GameCult.Networking.Tests // above the lowest unacked one and the payload above that sequence stays within 4 MiB. private const int Mib = 1024 * 1024; - private static CultNetRudpSession ConnectedFlowSession(uint initialSequence) - { - var session = new CultNetRudpSession(new CultNetRudpSessionOptions + private static CultNetRudpSession FlowSession(uint initialSequence) => + new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 0x464c4f57, InitialSequence = initialSequence, ResendDelayMs = 25 }); - session.Receive(new CultNetRudpPacket { PacketType = CultNetRudpPacketType.Accept, ConnectionId = 0x464c4f57, Sequence = 0, ChannelId = "control" }); - return session; + + // A sender and a receiver that have shaken hands: the receiver's watermark is seeded by the sender's + // Connect, and the sender's Connect is acknowledged, so nothing is pending on the sender. + private static (CultNetRudpSession Sender, CultNetRudpSession Receiver) ConnectedFlowPair(uint senderInitialSequence, uint receiverInitialSequence) + { + var sender = FlowSession(senderInitialSequence); + var receiver = FlowSession(receiverInitialSequence); + sender.Receive(receiver.AcceptConnect(sender.CreateConnect(0), 0), 0); + return (sender, receiver); } + private static CultNetRudpSession ConnectedFlowSession(uint initialSequence) => ConnectedFlowPair(initialSequence, 900).Sender; + private static IReadOnlyList FlowSend(CultNetRudpSession session, int payloadBytes) => session.SendMany("state", new byte[payloadBytes], new CultNetRudpSendOptions { Reliable = true }); @@ -1177,8 +1185,7 @@ namespace GameCult.Networking.Tests [Test] public void RudpSession_ALostPacketHoldsTheSender1023SequencesAheadAndIsStillDelivered() { - var sender = ConnectedFlowSession(1); - var receiver = ConnectedFlowSession(100); + var (sender, receiver) = ConnectedFlowPair(1, 100); var g = FlowSend(sender, 1).Single(); var admittedAfterG = 0; for (var index = 0; index < 4200; index++) From 0996fc96f59d21bfdcae9140a5314a460dee3aa2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 11:07:30 +0200 Subject: [PATCH 06/11] Ack Cut 3: the hold-buffer test acknowledges the gap by hand Cut 1's the_hold_buffer_takes_exactly_1024_frames test held 1,024 frames behind a sequence the receiver never sees, by sending them all above one unacked packet. Cut 3's flow window stops a sender 1,023 sequences above its lowest unacked packet, so the test's sender was refused at frame 1,023. The missing packet is now acknowledged by hand (the receiver never saw it), so the window slides and what the test probes is the receiver's hold buffer, not the sender. Co-Authored-By: Claude Sonnet 5.5 --- .../tests/rudp_connect_generation.rs | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/cultnet-rs/tests/rudp_connect_generation.rs b/packages/cultnet-rs/tests/rudp_connect_generation.rs index b456162c..92f83322 100644 --- a/packages/cultnet-rs/tests/rudp_connect_generation.rs +++ b/packages/cultnet-rs/tests/rudp_connect_generation.rs @@ -575,9 +575,30 @@ fn a_flush_that_a_timeout_ended_names_the_timeout() -> Result<()> { // Hold-buffer boundaries: 1,024 frames and 64 channels are held; one more is // refused. +/// An acknowledgement that names only `sequence`. +fn ack_naming(sequence: u32) -> CultNetRudpPacket { + CultNetRudpPacket { + packet_type: CultNetRudpPacketType::Ack, + connection_id: CONNECTION_ID, + sequence: 0, + ack: sequence, + ack_mask: 0, + channel_id: "control".into(), + reliable: false, + ordered: false, + sequenced: false, + fragment_id: 0, + fragment_index: 0, + fragment_count: 0, + payload: Vec::new(), + } +} + /// A connected pair with one sequence the receiver will never see, so every -/// later ordered frame waits behind it. The sender's window is kept open by -/// acknowledging what the receiver has. +/// later ordered frame waits behind it. The sender's flow window would stop it +/// 1,023 sequences above its lowest unacked packet, so the missing one is +/// acknowledged by hand (the receiver never saw it) and the rest by what the +/// receiver has: the receiver's hold buffer is what this probes, not the sender. fn held_behind_a_gap( frames: usize, channel_of: impl Fn(usize) -> String, @@ -585,7 +606,8 @@ fn held_behind_a_gap( let mut sender = session(1); let mut receiver = session(500); handshake(&mut sender, &mut receiver)?; - let _missing = sender.send("gap", b"lost".to_vec(), ordered())?; + let missing = sender.send("gap", b"lost".to_vec(), ordered())?; + sender.receive(&ack_naming(missing.sequence), 1)?; let mut held = 0; for index in 0..frames { let packet = sender.send(&channel_of(index), vec![0], ordered())?; From d7eacd1011948b690fa454ee6c847c99b77d29d8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:04:19 +0200 Subject: [PATCH 07/11] Ack Cut 3 F1: the 4 MiB flow bound counts acknowledged bytes above the lowest unacknowledged sequence The bound summed only packets still awaiting an ack. Once frames above a lost packet were acknowledged they stopped counting, so a receiver could be made to hold more than 4 MiB behind one loss, and its hold buffer ended the session. The sender now keeps the payload size of each acknowledged sequence above the lowest pending one until the lowest advances past it, in Rust, TypeScript, Python and C#. Tests per runtime: one lost packet, four acknowledged 1 MiB frames, the fifth refused until the gap closes; and the floor moving to a new lowest sequence. Co-Authored-By: Claude Sonnet 5.5 --- .../cultnet-py/src/cultnet_py/transport.py | 21 ++++++++-- packages/cultnet-py/tests/test_cultnet.py | 33 +++++++++++++++ packages/cultnet-rs/src/rudp.rs | 33 +++++++++++++-- packages/cultnet-rs/tests/rudp_flow_window.rs | 42 +++++++++++++++++++ packages/cultnet-ts/src/rudp.ts | 30 ++++++++++++- packages/cultnet-ts/test/cultnet.test.ts | 31 ++++++++++++++ src/GameCult.Networking/CultNetTransport.cs | 29 ++++++++++++- .../NetworkingTests.cs | 40 ++++++++++++++++++ 8 files changed, 249 insertions(+), 10 deletions(-) diff --git a/packages/cultnet-py/src/cultnet_py/transport.py b/packages/cultnet-py/src/cultnet_py/transport.py index a5927070..1e718aab 100644 --- a/packages/cultnet-py/src/cultnet_py/transport.py +++ b/packages/cultnet-py/src/cultnet_py/transport.py @@ -482,6 +482,9 @@ class CultNetRudpSession: self._received_sequences: set[int] = set() self._latest_sequenced_by_channel: dict[str, int] = {} self._pending_reliable: dict[int, _PendingReliablePacket] = {} + # Payload sizes of acknowledged reliable sequences above the lowest unacknowledged one. The receiver still + # holds those bytes behind the gap, so the flow window keeps counting them until the lowest sequence passes them. + self._acked_above_lowest: dict[int, int] = {} self._queued_reliable: deque[CultNetRudpPacket] = deque() # Every reliable sequence up to and including this one has been received # since the peer state was last reset. Only the handshake seeds it: the @@ -544,6 +547,7 @@ class CultNetRudpSession: self._awaiting_accept = False self._generation += 1 self._pending_reliable.clear() + self._acked_above_lowest.clear() self._queued_reliable.clear() def reset_peer_state(self) -> None: @@ -950,7 +954,7 @@ class CultNetRudpSession: lowest = min(self._pending_reliable) bytes_above = sum( len(pending.packet.payload) for pending_sequence, pending in self._pending_reliable.items() if pending_sequence > lowest - ) + ) + sum(self._acked_above_lowest.values()) return sequence - lowest <= self.FLOW_WINDOW_SEQUENCES and bytes_above + payload_length <= self.FLOW_WINDOW_BYTES def _admit_reliable_packets( @@ -984,10 +988,21 @@ class CultNetRudpSession: raise ValueError("RUDP reliable send queue is full") def _apply_acknowledgements(self, packet: CultNetRudpPacket) -> None: - self._pending_reliable.pop(packet.ack, None) + self._acknowledge(packet.ack) for bit in range(32): if packet.ack_mask & (1 << bit): - self._pending_reliable.pop(packet.ack - bit - 1, None) + self._acknowledge(packet.ack - bit - 1) + # Acknowledged sizes stop counting once the lowest unacknowledged sequence passes them, and all of them stop + # when nothing is unacknowledged. + lowest = min(self._pending_reliable, default=None) + self._acked_above_lowest = ( + {} if lowest is None else {sequence: length for sequence, length in self._acked_above_lowest.items() if sequence > lowest} + ) + + def _acknowledge(self, sequence: int) -> None: + pending = self._pending_reliable.pop(sequence, None) + if pending is not None: + self._acked_above_lowest[sequence] = len(pending.packet.payload) def _seed_received(self, sequence: int) -> None: """The handshake's one act on the watermark: the peer's Connect or Accept diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index 8105c83d..f11eb1ce 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -980,6 +980,39 @@ class CultNetTests(unittest.TestCase): promoted = sender.receive(self._flow_ack(g.sequence), 1).ready_to_send self.assertEqual([p.sequence for p in promoted], [g.sequence + 2, g.sequence + 3]) + def test_cultnet_rudp_acknowledged_bytes_above_a_lost_packet_still_count_against_4_mib(self) -> None: + sender = self._connected_flow_session(1) + g = self._flow_send(sender, 1)[0] + # g is lost. Each 1 MiB frame above it is delivered and acknowledged, and the receiver still holds it behind + # the gap, so it keeps counting. + for offset in range(1, 5): + self.assertEqual([p.sequence for p in self._flow_send(sender, self.MIB)], [g.sequence + offset]) + sender.receive(self._flow_ack(g.sequence + offset), 1) + self.assertEqual(len(self._flow_send(sender, self.MIB)), 0) + self.assertEqual(sender.queued_reliable_packet_count, 1) + with self.assertRaises(ValueError): + sender.send("state", bytes(self.MIB), CultNetRudpSendOptions(reliable=True)) + + # g arrives: nothing is held behind a gap any more, and the queue drains. + promoted = sender.receive(self._flow_ack(g.sequence), 2).ready_to_send + self.assertEqual([p.sequence for p in promoted], [g.sequence + 5]) + + def test_cultnet_rudp_acknowledged_bytes_count_against_the_lowest_unacked_sequence_as_it_advances(self) -> None: + sender = self._connected_flow_session(1) + g = self._flow_send(sender, 1)[0] + h = self._flow_send(sender, 1)[0] + for _ in range(3): + self._flow_send(sender, self.MIB) + # g and the 3 MiB above h are acknowledged; h is lost. + for offset in (2, 3, 4, 0): + sender.receive(self._flow_ack(g.sequence + offset), 1) + self.assertEqual([p.sequence for p in self._flow_send(sender, self.MIB)], [g.sequence + 5]) + # 4 MiB sit above h, acknowledged or not: one more byte does not fit. + self.assertEqual(len(self._flow_send(sender, 1)), 0) + # h arrives, the floor moves to g + 5, and the byte fits. + promoted = sender.receive(self._flow_ack(h.sequence), 2).ready_to_send + self.assertEqual([p.sequence for p in promoted], [g.sequence + 6]) + def test_cultnet_rudp_socket_transport_handshakes_and_carries_reliable_ordered_schema_frames(self) -> None: server_socket = bind_udp_socket() client_socket = bind_udp_socket() diff --git a/packages/cultnet-rs/src/rudp.rs b/packages/cultnet-rs/src/rudp.rs index 7d8e6f12..f4b0616f 100644 --- a/packages/cultnet-rs/src/rudp.rs +++ b/packages/cultnet-rs/src/rudp.rs @@ -251,6 +251,11 @@ pub struct CultNetRudpSession { received_sequences: BTreeSet, latest_sequenced_by_channel: BTreeMap, pending_reliable: BTreeMap, + /// Payload sizes of acknowledged reliable sequences above the lowest + /// unacknowledged one. The receiver still holds those bytes behind the gap, + /// so the flow window keeps counting them until the lowest sequence + /// advances past them. + acked_above_lowest: BTreeMap, queued_reliable: VecDeque<(CultNetRudpPacket, Option)>, reliable_packets_expired: u64, /// Every reliable sequence up to and including this one has been received @@ -291,6 +296,7 @@ impl CultNetRudpSession { received_sequences: BTreeSet::new(), latest_sequenced_by_channel: BTreeMap::new(), pending_reliable: BTreeMap::new(), + acked_above_lowest: BTreeMap::new(), queued_reliable: VecDeque::new(), reliable_packets_expired: 0, received_through: None, @@ -375,6 +381,7 @@ impl CultNetRudpSession { self.awaiting_accept = false; self.generation += 1; self.pending_reliable.clear(); + self.acked_above_lowest.clear(); self.queued_reliable.clear(); } @@ -1080,6 +1087,7 @@ impl CultNetRudpSession { .retain(|_, pending| pending.expires_at_ms.is_none_or(|at| now_ms <= at)); self.queued_reliable .retain(|(_, expires_at_ms)| expires_at_ms.is_none_or(|at| now_ms <= at)); + self.forget_acked_below_lowest(); let after = self.pending_reliable.len() + self.queued_reliable.len(); self.reliable_packets_expired = self .reliable_packets_expired @@ -1096,12 +1104,14 @@ impl CultNetRudpSession { let Some((&lowest, _)) = self.pending_reliable.first_key_value() else { return true; }; - let bytes_above: usize = self + let pending_bytes: usize = self .pending_reliable .iter() .filter(|(pending_sequence, _)| **pending_sequence > lowest) .map(|(_, pending)| pending.packet.payload.len()) .sum(); + let acked_bytes: usize = self.acked_above_lowest.values().sum(); + let bytes_above = pending_bytes + acked_bytes; sequence.saturating_sub(lowest) <= RUDP_FLOW_WINDOW_SEQUENCES && bytes_above + payload_len <= RUDP_FLOW_WINDOW_BYTES } @@ -1163,12 +1173,29 @@ impl CultNetRudpSession { } fn apply_acknowledgements(&mut self, packet: &CultNetRudpPacket) { - self.pending_reliable.remove(&packet.ack); + self.acknowledge(packet.ack); for bit in 0..32 { if (packet.ack_mask & (1_u32 << bit)) != 0 && packet.ack > bit { - self.pending_reliable.remove(&(packet.ack - bit - 1)); + self.acknowledge(packet.ack - bit - 1); } } + self.forget_acked_below_lowest(); + } + + fn acknowledge(&mut self, sequence: u32) { + if let Some(pending) = self.pending_reliable.remove(&sequence) { + self.acked_above_lowest + .insert(sequence, pending.packet.payload.len()); + } + } + + /// Acknowledged sizes stop counting once the lowest unacknowledged + /// sequence passes them, and all of them stop when nothing is unacknowledged. + fn forget_acked_below_lowest(&mut self) { + match self.pending_reliable.first_key_value() { + Some((&lowest, _)) => self.acked_above_lowest.retain(|sequence, _| *sequence > lowest), + None => self.acked_above_lowest.clear(), + } } /// The handshake's one act on the watermark: the peer's Connect or Accept diff --git a/packages/cultnet-rs/tests/rudp_flow_window.rs b/packages/cultnet-rs/tests/rudp_flow_window.rs index 73b7f8e6..bc7493cc 100644 --- a/packages/cultnet-rs/tests/rudp_flow_window.rs +++ b/packages/cultnet-rs/tests/rudp_flow_window.rs @@ -172,3 +172,45 @@ fn a_small_packet_does_not_overtake_a_queued_large_one() -> Result<()> { assert_eq!(sequences(&promoted), vec![g.sequence + 2, g.sequence + 3]); Ok(()) } + +#[test] +fn acknowledged_bytes_above_a_lost_packet_still_count_against_4_mib() -> Result<()> { + let mut sender = connected(1)?; + let g = send(&mut sender, 1)?.remove(0); + // g is lost. Each 1 MiB frame above it is delivered and acknowledged, and + // the receiver still holds it behind the gap, so it keeps counting. + for offset in 1..=4u32 { + let sent = send(&mut sender, MIB)?; + assert_eq!(sequences(&sent), vec![g.sequence + offset]); + sender.receive(&ack_for(g.sequence + offset), 1)?; + } + assert!(send(&mut sender, MIB)?.is_empty()); + assert_eq!(sender.queued_reliable_packet_count(), 1); + assert!(sender.send("state", vec![7; MIB], reliable()).is_err()); + + // g arrives: nothing is held behind a gap any more, and the queue drains. + let promoted = sender.receive(&ack_for(g.sequence), 2)?.ready_to_send; + assert_eq!(sequences(&promoted), vec![g.sequence + 5]); + Ok(()) +} + +#[test] +fn acknowledged_bytes_count_against_the_lowest_unacked_sequence_as_it_advances() -> Result<()> { + let mut sender = connected(1)?; + let g = send(&mut sender, 1)?.remove(0); + let h = send(&mut sender, 1)?.remove(0); + for _ in 0..3 { + send(&mut sender, MIB)?; + } + // g and the 3 MiB above h are acknowledged; h is lost. + for offset in [2, 3, 4, 0] { + sender.receive(&ack_for(g.sequence + offset), 1)?; + } + assert_eq!(sequences(&send(&mut sender, MIB)?), vec![g.sequence + 5]); + // 4 MiB sit above h, acknowledged or not: one more byte does not fit. + assert!(send(&mut sender, 1)?.is_empty()); + // h arrives, the floor moves to g + 5, and the byte fits. + let promoted = sender.receive(&ack_for(h.sequence), 2)?.ready_to_send; + assert_eq!(sequences(&promoted), vec![g.sequence + 6]); + Ok(()) +} diff --git a/packages/cultnet-ts/src/rudp.ts b/packages/cultnet-ts/src/rudp.ts index e072e225..ae696536 100644 --- a/packages/cultnet-ts/src/rudp.ts +++ b/packages/cultnet-ts/src/rudp.ts @@ -203,6 +203,9 @@ export class CultNetRudpSession { readonly #receivedSequences = new Set(); readonly #latestSequencedByChannel = new Map(); readonly #pendingReliable = new Map(); + /// Payload sizes of acknowledged reliable sequences above the lowest unacknowledged one. The receiver still holds + /// those bytes behind the gap, so the flow window keeps counting them until the lowest sequence passes them. + readonly #ackedAboveLowest = new Map(); readonly #queuedReliable: CultNetRudpPacket[] = []; /// Every reliable sequence up to and including this one has been received /// since the peer state was last reset. Only the handshake seeds it: the @@ -285,6 +288,7 @@ export class CultNetRudpSession { this.#awaitingAccept = false; this.#generation += 1; this.#pendingReliable.clear(); + this.#ackedAboveLowest.clear(); this.#queuedReliable.splice(0); } @@ -756,6 +760,7 @@ export class CultNetRudpSession { for (const [pendingSequence, pending] of this.#pendingReliable) { if (pendingSequence > lowest) bytesAbove += pending.packet.payload?.length ?? 0; } + for (const length of this.#ackedAboveLowest.values()) bytesAbove += length; return sequence - lowest <= RUDP_FLOW_WINDOW_SEQUENCES && bytesAbove + payloadLength <= RUDP_FLOW_WINDOW_BYTES; } @@ -794,12 +799,33 @@ export class CultNetRudpSession { } #applyAcknowledgements(packet: CultNetRudpPacket): void { - this.#pendingReliable.delete(packet.ack); + this.#acknowledge(packet.ack); for (let bit = 0; bit < 32; bit += 1) { if ((packet.ackMask & (1 << bit)) !== 0) { - this.#pendingReliable.delete(packet.ack - bit - 1); + this.#acknowledge(packet.ack - bit - 1); } } + this.#forgetAckedBelowLowest(); + } + + #acknowledge(sequence: number): void { + const pending = this.#pendingReliable.get(sequence); + if (pending === undefined) return; + this.#pendingReliable.delete(sequence); + this.#ackedAboveLowest.set(sequence, pending.packet.payload?.length ?? 0); + } + + /// Acknowledged sizes stop counting once the lowest unacknowledged sequence passes them, and all of them stop + /// when nothing is unacknowledged. + #forgetAckedBelowLowest(): void { + if (this.#pendingReliable.size === 0) { + this.#ackedAboveLowest.clear(); + return; + } + const lowest = Math.min(...this.#pendingReliable.keys()); + for (const sequence of this.#ackedAboveLowest.keys()) { + if (sequence <= lowest) this.#ackedAboveLowest.delete(sequence); + } } /// The handshake's one act on the watermark: the peer's Connect or Accept is diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index f3c1612b..c921cb0d 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -1524,6 +1524,37 @@ test("a small packet does not overtake a queued large one", () => { assert.deepEqual(sequencesOf(sender.receive(flowAck(g.sequence), 1).readyToSend ?? []), [g.sequence + 2, g.sequence + 3]); }); +test("acknowledged bytes above a lost packet still count against 4 MiB", () => { + const sender = connectedFlowSession(1); + const g = flowSend(sender, 1)[0]!; + // g is lost. Each 1 MiB frame above it is delivered and acknowledged, and the receiver still holds it behind the + // gap, so it keeps counting. + for (let offset = 1; offset <= 4; offset++) { + assert.deepEqual(sequencesOf(flowSend(sender, MIB)), [g.sequence + offset]); + sender.receive(flowAck(g.sequence + offset), 1); + } + assert.equal(flowSend(sender, MIB).length, 0); + assert.equal(sender.queuedReliablePacketCount, 1); + assert.throws(() => sender.send("state", new Uint8Array(MIB), { reliable: true })); + + // g arrives: nothing is held behind a gap any more, and the queue drains. + assert.deepEqual(sequencesOf(sender.receive(flowAck(g.sequence), 2).readyToSend ?? []), [g.sequence + 5]); +}); + +test("acknowledged bytes count against the lowest unacked sequence as it advances", () => { + const sender = connectedFlowSession(1); + const g = flowSend(sender, 1)[0]!; + const h = flowSend(sender, 1)[0]!; + for (let index = 0; index < 3; index++) flowSend(sender, MIB); + // g and the 3 MiB above h are acknowledged; h is lost. + for (const offset of [2, 3, 4, 0]) sender.receive(flowAck(g.sequence + offset), 1); + assert.deepEqual(sequencesOf(flowSend(sender, MIB)), [g.sequence + 5]); + // 4 MiB sit above h, acknowledged or not: one more byte does not fit. + assert.equal(flowSend(sender, 1).length, 0); + // h arrives, the floor moves to g + 5, and the byte fits. + assert.deepEqual(sequencesOf(sender.receive(flowAck(h.sequence), 2).readyToSend ?? []), [g.sequence + 6]); +}); + test("CultNet contracts encode legacy bytes without Node Buffer authority", () => { const originalBuffer = globalThis.Buffer; try { diff --git a/src/GameCult.Networking/CultNetTransport.cs b/src/GameCult.Networking/CultNetTransport.cs index 6501fd3d..9731b7f9 100644 --- a/src/GameCult.Networking/CultNetTransport.cs +++ b/src/GameCult.Networking/CultNetTransport.cs @@ -939,6 +939,9 @@ namespace GameCult.Networking private readonly Dictionary _latestSequencedByChannel = new Dictionary(StringComparer.Ordinal); private readonly object _pendingReliableGate = new object(); private readonly Dictionary _pendingReliable = new Dictionary(); + // Payload sizes of acknowledged reliable sequences above the lowest unacknowledged one. The receiver still + // holds those bytes behind the gap, so the flow window keeps counting them until the lowest sequence passes them. + private readonly Dictionary _ackedAboveLowest = new Dictionary(); private readonly Queue _queuedReliable = new Queue(); // Every reliable sequence up to and including this one has been received since the peer state // was last reset. Only the handshake seeds it: the peer's Connect on the accepting side, the @@ -1424,6 +1427,7 @@ namespace GameCult.Networking lock (_pendingReliableGate) { _pendingReliable.Clear(); + _ackedAboveLowest.Clear(); _queuedReliable.Clear(); } } @@ -1595,6 +1599,8 @@ namespace GameCult.Networking if (pending.Key > lowest) bytesAbove += pending.Value.Packet.Payload.Length; } + foreach (var length in _ackedAboveLowest.Values) + bytesAbove += length; return sequence - lowest <= FlowWindowSequences && bytesAbove + payloadLength <= FlowWindowBytes; } @@ -1665,17 +1671,36 @@ namespace GameCult.Networking { lock (_pendingReliableGate) { - _pendingReliable.Remove(packet.Ack); + Acknowledge(packet.Ack); for (var bit = 0; bit < 32; bit++) { if ((packet.AckMask & (1u << bit)) != 0 && packet.Ack > bit) { - _pendingReliable.Remove(packet.Ack - (uint)bit - 1); + Acknowledge(packet.Ack - (uint)bit - 1); } } + // Acknowledged sizes stop counting once the lowest unacknowledged sequence passes them, and all + // of them stop when nothing is unacknowledged. + if (_pendingReliable.Count == 0) + { + _ackedAboveLowest.Clear(); + } + else + { + var lowest = _pendingReliable.Keys.Min(); + foreach (var sequence in _ackedAboveLowest.Keys.Where(sequence => sequence <= lowest).ToArray()) + _ackedAboveLowest.Remove(sequence); + } } } + // Callers hold the pending gate. + private void Acknowledge(uint sequence) + { + if (_pendingReliable.Remove(sequence, out var pending)) + _ackedAboveLowest[sequence] = pending.Packet.Payload.Length; + } + private static bool PacketAcknowledges(CultNetRudpPacket packet, uint sequence) { if (packet.Ack == sequence) diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 15091ec3..fbd422ce 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1265,6 +1265,46 @@ namespace GameCult.Networking.Tests Assert.That(promoted.Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 2, g.Sequence + 3 })); } + [Test] + public void RudpSession_AcknowledgedBytesAboveALostPacketStillCountAgainst4MiB() + { + var sender = ConnectedFlowSession(1); + var g = FlowSend(sender, 1).Single(); + // g is lost. Each 1 MiB frame above it is delivered and acknowledged, and the receiver still holds it + // behind the gap, so it keeps counting. + for (uint offset = 1; offset <= 4; offset++) + { + Assert.That(FlowSend(sender, Mib).Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + offset })); + sender.Receive(FlowAck(g.Sequence + offset), 1); + } + Assert.That(FlowSend(sender, Mib), Is.Empty); + Assert.That(sender.QueuedReliablePacketCount, Is.EqualTo(1)); + Assert.Throws(() => sender.Send("state", new byte[Mib], new CultNetRudpSendOptions { Reliable = true })); + + // g arrives: nothing is held behind a gap any more, and the queue drains. + var promoted = sender.Receive(FlowAck(g.Sequence), 2).ReadyToSend; + Assert.That(promoted.Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 5 })); + } + + [Test] + public void RudpSession_AcknowledgedBytesCountAgainstTheLowestUnackedSequenceAsItAdvances() + { + var sender = ConnectedFlowSession(1); + var g = FlowSend(sender, 1).Single(); + var h = FlowSend(sender, 1).Single(); + for (var index = 0; index < 3; index++) + FlowSend(sender, Mib); + // g and the 3 MiB above h are acknowledged; h is lost. + foreach (uint offset in new uint[] { 2, 3, 4, 0 }) + sender.Receive(FlowAck(g.Sequence + offset), 1); + Assert.That(FlowSend(sender, Mib).Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 5 })); + // 4 MiB sit above h, acknowledged or not: one more byte does not fit. + Assert.That(FlowSend(sender, 1), Is.Empty); + // h arrives, the floor moves to g + 5, and the byte fits. + var promoted = sender.Receive(FlowAck(h.Sequence), 2).ReadyToSend; + Assert.That(promoted.Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 6 })); + } + [Test] public void RudpSocketTransport_HandshakesAndCarriesReliableOrderedSchemaFrames() { From 0daf6f4cc0279d02738a10c0a6a4faa047c2571b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:07:28 +0200 Subject: [PATCH 08/11] Ack Cut 3 F1: Rust pins that an expired lowest packet releases the acknowledged bytes above it Co-Authored-By: Claude Sonnet 5.5 --- packages/cultnet-rs/tests/rudp_flow_window.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/cultnet-rs/tests/rudp_flow_window.rs b/packages/cultnet-rs/tests/rudp_flow_window.rs index bc7493cc..94869ce6 100644 --- a/packages/cultnet-rs/tests/rudp_flow_window.rs +++ b/packages/cultnet-rs/tests/rudp_flow_window.rs @@ -214,3 +214,36 @@ fn acknowledged_bytes_count_against_the_lowest_unacked_sequence_as_it_advances() assert_eq!(sequences(&promoted), vec![g.sequence + 6]); Ok(()) } + +#[test] +fn acknowledged_bytes_stop_counting_when_the_lowest_unacked_packet_expires() -> Result<()> { + let mut sender = connected(1)?; + let expiring = CultNetRudpSendOptions { + reliable: true, + now_ms: 0, + reliable_expire_after_ms: Some(100), + ..Default::default() + }; + let g = sender + .send_many("state", vec![7], expiring, None)? + .remove(0); + for offset in 1..=4u32 { + assert_eq!(send(&mut sender, MIB)?.len(), 1); + sender.receive(&ack_for(g.sequence + offset), 1)?; + } + assert!(sender.send("state", vec![7; MIB], reliable()).is_err()); + + // g's deadline passes and it leaves the window; nothing is unacked, so the + // acknowledged bytes no longer sit above any gap. + let at = |now_ms| CultNetRudpSendOptions { + reliable: true, + now_ms, + ..Default::default() + }; + let first = sender.send_many("state", vec![7], at(200), None)?; + assert_eq!(first.len(), 1); + for _ in 0..4 { + assert_eq!(sender.send_many("state", vec![7; MIB], at(200), None)?.len(), 1); + } + Ok(()) +} From 436b4237629362e90386a603f6144077e489d27d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 12:12:23 +0200 Subject: [PATCH 09/11] Ack Cut 3 F1: the acknowledged-bytes tests pin pruning on the ack that closes the gap The bytes counted above a lost packet stop counting once it is acknowledged; every runtime now asserts that another MiB is admitted after the gap closes. Rust prunes on expiry only when something expired, so the acknowledgement path owns the pruning it can observe. Co-Authored-By: Claude Sonnet 5.5 --- packages/cultnet-py/tests/test_cultnet.py | 2 ++ packages/cultnet-rs/src/rudp.rs | 4 +++- packages/cultnet-rs/tests/rudp_flow_window.rs | 2 ++ packages/cultnet-ts/test/cultnet.test.ts | 2 ++ tests/GameCult.Networking.Tests/NetworkingTests.cs | 2 ++ 5 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index f11eb1ce..4882e2d9 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -996,6 +996,8 @@ class CultNetTests(unittest.TestCase): # g arrives: nothing is held behind a gap any more, and the queue drains. promoted = sender.receive(self._flow_ack(g.sequence), 2).ready_to_send self.assertEqual([p.sequence for p in promoted], [g.sequence + 5]) + # The bytes above the old gap no longer count: another MiB fits. + self.assertEqual([p.sequence for p in self._flow_send(sender, self.MIB)], [g.sequence + 6]) def test_cultnet_rudp_acknowledged_bytes_count_against_the_lowest_unacked_sequence_as_it_advances(self) -> None: sender = self._connected_flow_session(1) diff --git a/packages/cultnet-rs/src/rudp.rs b/packages/cultnet-rs/src/rudp.rs index f4b0616f..bf611a15 100644 --- a/packages/cultnet-rs/src/rudp.rs +++ b/packages/cultnet-rs/src/rudp.rs @@ -1087,8 +1087,10 @@ impl CultNetRudpSession { .retain(|_, pending| pending.expires_at_ms.is_none_or(|at| now_ms <= at)); self.queued_reliable .retain(|(_, expires_at_ms)| expires_at_ms.is_none_or(|at| now_ms <= at)); - self.forget_acked_below_lowest(); let after = self.pending_reliable.len() + self.queued_reliable.len(); + if after != before { + self.forget_acked_below_lowest(); + } self.reliable_packets_expired = self .reliable_packets_expired .saturating_add((before - after) as u64); diff --git a/packages/cultnet-rs/tests/rudp_flow_window.rs b/packages/cultnet-rs/tests/rudp_flow_window.rs index 94869ce6..85c7a864 100644 --- a/packages/cultnet-rs/tests/rudp_flow_window.rs +++ b/packages/cultnet-rs/tests/rudp_flow_window.rs @@ -191,6 +191,8 @@ fn acknowledged_bytes_above_a_lost_packet_still_count_against_4_mib() -> Result< // g arrives: nothing is held behind a gap any more, and the queue drains. let promoted = sender.receive(&ack_for(g.sequence), 2)?.ready_to_send; assert_eq!(sequences(&promoted), vec![g.sequence + 5]); + // The bytes above the old gap no longer count: another MiB fits. + assert_eq!(sequences(&send(&mut sender, MIB)?), vec![g.sequence + 6]); Ok(()) } diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index c921cb0d..17865e03 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -1539,6 +1539,8 @@ test("acknowledged bytes above a lost packet still count against 4 MiB", () => { // g arrives: nothing is held behind a gap any more, and the queue drains. assert.deepEqual(sequencesOf(sender.receive(flowAck(g.sequence), 2).readyToSend ?? []), [g.sequence + 5]); + // The bytes above the old gap no longer count: another MiB fits. + assert.deepEqual(sequencesOf(flowSend(sender, MIB)), [g.sequence + 6]); }); test("acknowledged bytes count against the lowest unacked sequence as it advances", () => { diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index fbd422ce..d66d2aff 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1284,6 +1284,8 @@ namespace GameCult.Networking.Tests // g arrives: nothing is held behind a gap any more, and the queue drains. var promoted = sender.Receive(FlowAck(g.Sequence), 2).ReadyToSend; Assert.That(promoted.Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 5 })); + // The bytes above the old gap no longer count: another MiB fits. + Assert.That(FlowSend(sender, Mib).Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 6 })); } [Test] From 1833df58d46b1d7f6e8cdc15d2f05ed373cd3ab2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 14:46:20 +0200 Subject: [PATCH 10/11] cultnet-rs: a direct send measures the window after reclaiming expired sends `send` checked `window_admits` before `send_many` purged deadline-passed packets, so the first direct send after the lowest unacked packet expired was refused for acknowledged bytes that `send_many` would already have released. `send` now purges first, as `send_many` does. Co-Authored-By: Claude Opus 5.5 --- packages/cultnet-rs/src/rudp.rs | 5 +++ packages/cultnet-rs/tests/rudp_flow_window.rs | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/packages/cultnet-rs/src/rudp.rs b/packages/cultnet-rs/src/rudp.rs index bf611a15..9e8c4a1c 100644 --- a/packages/cultnet-rs/src/rudp.rs +++ b/packages/cultnet-rs/src/rudp.rs @@ -574,6 +574,11 @@ impl CultNetRudpSession { payload: Vec, options: CultNetRudpSendOptions, ) -> Result { + // Measure the window as `send_many` will: without the deadline-passed + // sends it is about to reclaim. + if options.reliable { + self.purge_expired_reliable(options.now_ms); + } if options.reliable && (!self.queued_reliable.is_empty() || !self.window_admits(self.next_sequence, payload.len())) diff --git a/packages/cultnet-rs/tests/rudp_flow_window.rs b/packages/cultnet-rs/tests/rudp_flow_window.rs index 85c7a864..58065970 100644 --- a/packages/cultnet-rs/tests/rudp_flow_window.rs +++ b/packages/cultnet-rs/tests/rudp_flow_window.rs @@ -249,3 +249,34 @@ fn acknowledged_bytes_stop_counting_when_the_lowest_unacked_packet_expires() -> } Ok(()) } + +#[test] +fn a_direct_send_is_not_refused_for_bytes_whose_lowest_packet_has_expired() -> Result<()> { + let mut sender = connected(1)?; + let expiring = CultNetRudpSendOptions { + reliable: true, + now_ms: 0, + reliable_expire_after_ms: Some(100), + ..Default::default() + }; + let g = sender + .send_many("state", vec![7], expiring, None)? + .remove(0); + for offset in 1..=4u32 { + assert_eq!(send(&mut sender, MIB)?.len(), 1); + sender.receive(&ack_for(g.sequence + offset), 1)?; + } + assert!(sender.send("state", vec![7; MIB], reliable()).is_err()); + + // The first call after g's deadline is a direct send: it measures the + // window without g, as `send_many` would, and goes on the wire. + let at_200 = CultNetRudpSendOptions { + reliable: true, + now_ms: 200, + ..Default::default() + }; + let sent = sender.send("state", vec![7; MIB], at_200)?; + assert_eq!(sent.sequence, g.sequence + 5); + assert_eq!(sender.outstanding_reliable_packet_count(), 1); + Ok(()) +} From b4cee00af2fc10acaed23fdbb41e087c7927c23a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 14:46:38 +0200 Subject: [PATCH 11/11] Ack Cut 3: a server's new generation does not inherit acknowledged bytes A server accepting a fresh Connect can send before its Accept is acknowledged, so no ack of the new generation prunes the acknowledged sizes; only `end_session` clearing them keeps the ended generation's bytes from counting against the new window. Rust, TypeScript, Python and C# each pin it: 4 MiB acknowledged above a lost packet, a fresh Connect, and the server's first 1 MiB send of the new generation goes out. Co-Authored-By: Claude Opus 5.5 --- packages/cultnet-py/tests/test_cultnet.py | 19 +++++++++++++ packages/cultnet-rs/tests/rudp_flow_window.rs | 28 +++++++++++++++++++ packages/cultnet-ts/test/cultnet.test.ts | 20 +++++++++++++ .../NetworkingTests.cs | 24 ++++++++++++++++ 4 files changed, 91 insertions(+) diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index df6e2340..4c740ac9 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -1017,6 +1017,25 @@ class CultNetTests(unittest.TestCase): promoted = sender.receive(self._flow_ack(h.sequence), 2).ready_to_send self.assertEqual([p.sequence for p in promoted], [g.sequence + 6]) + def test_cultnet_rudp_a_new_generation_on_the_server_does_not_inherit_acknowledged_bytes(self) -> None: + # A server that accepts a fresh Connect may send before its Accept is acknowledged, so no acknowledgement of + # the new generation has moved the floor yet. Bytes the ended generation had acknowledged above its own lost + # packet belong to no receiver's hold any more and must not count. + server = self._flow_session(500) + accept = server.accept_connect(self._flow_session(1).create_connect(0), 0) + server.receive(self._flow_ack(accept.sequence), 0) + g = self._flow_send(server, 1)[0] + for offset in range(1, 5): + self.assertEqual([p.sequence for p in self._flow_send(server, self.MIB)], [g.sequence + offset]) + server.receive(self._flow_ack(g.sequence + offset), 1) + self.assertEqual(len(self._flow_send(server, self.MIB)), 0) + + next_accept = server.accept_connect(self._flow_session(200_000).create_connect(2), 2) + self.assertEqual(next_accept.packet_type, CultNetRudpPacketType.ACCEPT) + # The Accept is unacknowledged and the only thing pending: 1 MiB fits above it. + sent = server.send("state", bytes(self.MIB), CultNetRudpSendOptions(reliable=True)) + self.assertEqual(sent.sequence, next_accept.sequence + 1) + def test_cultnet_rudp_socket_transport_handshakes_and_carries_reliable_ordered_schema_frames(self) -> None: server_socket = bind_udp_socket() client_socket = bind_udp_socket() diff --git a/packages/cultnet-rs/tests/rudp_flow_window.rs b/packages/cultnet-rs/tests/rudp_flow_window.rs index 58065970..9f10eb50 100644 --- a/packages/cultnet-rs/tests/rudp_flow_window.rs +++ b/packages/cultnet-rs/tests/rudp_flow_window.rs @@ -280,3 +280,31 @@ fn a_direct_send_is_not_refused_for_bytes_whose_lowest_packet_has_expired() -> R assert_eq!(sender.outstanding_reliable_packet_count(), 1); Ok(()) } + +/// A server that accepts a fresh Connect may send before its Accept is +/// acknowledged, so no acknowledgement of the new generation has moved the +/// floor yet. Bytes the ended generation had acknowledged above its own lost +/// packet belong to no receiver's hold any more and must not count. +#[test] +fn a_new_generation_on_the_server_does_not_inherit_acknowledged_bytes() -> Result<()> { + let mut server = session(500); + let mut first_client = session(1); + let connect = first_client.create_connect(0, Vec::new())?; + let accept = server.accept_connect(&connect, 0, Vec::new())?; + server.receive(&ack_for(accept.sequence), 0)?; + let g = send(&mut server, 1)?.remove(0); + for offset in 1..=4u32 { + assert_eq!(sequences(&send(&mut server, MIB)?), vec![g.sequence + offset]); + server.receive(&ack_for(g.sequence + offset), 1)?; + } + assert!(send(&mut server, MIB)?.is_empty()); + + let mut second_client = session(200_000); + let connect = second_client.create_connect(2, Vec::new())?; + let accept = server.accept_connect(&connect, 2, Vec::new())?; + assert_eq!(accept.packet_type, CultNetRudpPacketType::Accept); + // The Accept is unacknowledged and the only thing pending: 1 MiB fits above it. + let sent = server.send("state", vec![7; MIB], reliable())?; + assert_eq!(sent.sequence, accept.sequence + 1); + Ok(()) +} diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index bf3f64bb..e6b62bdb 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -1558,6 +1558,26 @@ test("acknowledged bytes count against the lowest unacked sequence as it advance assert.deepEqual(sequencesOf(sender.receive(flowAck(h.sequence), 2).readyToSend ?? []), [g.sequence + 6]); }); +// A server that accepts a fresh Connect may send before its Accept is acknowledged, so no acknowledgement of the new +// generation has moved the floor yet. Bytes the ended generation had acknowledged above its own lost packet belong to +// no receiver's hold any more and must not count. +test("a new generation on the server does not inherit acknowledged bytes", () => { + const server = flowSession(500); + const accept = server.acceptConnect(flowSession(1).createConnect(0), 0); + server.receive(flowAck(accept.sequence), 0); + const g = flowSend(server, 1)[0]!; + for (let offset = 1; offset <= 4; offset++) { + assert.deepEqual(sequencesOf(flowSend(server, MIB)), [g.sequence + offset]); + server.receive(flowAck(g.sequence + offset), 1); + } + assert.equal(flowSend(server, MIB).length, 0); + + const nextAccept = server.acceptConnect(flowSession(200_000).createConnect(2), 2); + assert.equal(nextAccept.packetType, "accept"); + // The Accept is unacknowledged and the only thing pending: 1 MiB fits above it. + assert.equal(server.send("state", new Uint8Array(MIB), { reliable: true }).sequence, nextAccept.sequence + 1); +}); + test("CultNet contracts encode legacy bytes without Node Buffer authority", () => { const originalBuffer = globalThis.Buffer; try { diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 56d0483b..5a1f570f 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1307,6 +1307,30 @@ namespace GameCult.Networking.Tests Assert.That(promoted.Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 6 })); } + // A server that accepts a fresh Connect may send before its Accept is acknowledged, so no acknowledgement of + // the new generation has moved the floor yet. Bytes the ended generation had acknowledged above its own lost + // packet belong to no receiver's hold any more and must not count. + [Test] + public void RudpSession_ANewGenerationOnTheServerDoesNotInheritAcknowledgedBytes() + { + var server = FlowSession(500); + var accept = server.AcceptConnect(FlowSession(1).CreateConnect(0), 0); + server.Receive(FlowAck(accept.Sequence), 0); + var g = FlowSend(server, 1).Single(); + for (uint offset = 1; offset <= 4; offset++) + { + Assert.That(FlowSend(server, Mib).Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + offset })); + server.Receive(FlowAck(g.Sequence + offset), 1); + } + Assert.That(FlowSend(server, Mib), Is.Empty); + + var nextAccept = server.AcceptConnect(FlowSession(200_000).CreateConnect(2), 2); + Assert.That(nextAccept.PacketType, Is.EqualTo(CultNetRudpPacketType.Accept)); + // The Accept is unacknowledged and the only thing pending: 1 MiB fits above it. + var sent = server.Send("state", new byte[Mib], new CultNetRudpSendOptions { Reliable = true }); + Assert.That(sent.Sequence, Is.EqualTo(nextAccept.Sequence + 1)); + } + [Test] public void RudpSocketTransport_HandshakesAndCarriesReliableOrderedSchemaFrames() {