Merge ack Cut 3: the 4 MiB send bound counts every byte above the lowest unacknowledged packet

F1: acknowledged sequences above a gap still count until the lowest
advances, in Rust, TS, Python and C#. Rust direct send purges expired
packets before the window check. Soul passes: F1 held; batch 2 held with
13 own mutants killed and interop 27/27 (ack1d-interop:2). Only docs
differ from the verified tree. Follow-up: cultmesh-rs Cargo.lock stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 15:36:10 +02:00
commit 1210fdc7ae
10 changed files with 1098 additions and 44 deletions

View file

@ -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

View file

@ -438,6 +438,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")
@ -478,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
@ -540,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:
@ -656,7 +664,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]
@ -935,22 +943,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
) + 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(
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)
@ -963,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

View file

@ -879,6 +879,163 @@ 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 _flow_session(self, initial_sequence: int) -> CultNetRudpSession:
return CultNetRudpSession(
CultNetRudpSessionOptions(connection_id=self.FLOW_CONNECTION_ID, initial_sequence=initial_sequence)
)
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, ...]:
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, receiver = self._connected_flow_pair(1, 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)
# 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
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])
# 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)
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_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()

View file

@ -53,6 +53,13 @@ const RUDP_RECEIVE_AHEAD_WINDOW: u32 = 1_024;
const RUDP_CONNECT_ATTEMPT_MS: u64 = 3_000;
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 {
@ -244,6 +251,11 @@ pub struct CultNetRudpSession {
received_sequences: BTreeSet<u32>,
latest_sequenced_by_channel: BTreeMap<String, u32>,
pending_reliable: BTreeMap<u32, 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
/// advances past them.
acked_above_lowest: BTreeMap<u32, usize>,
queued_reliable: VecDeque<(CultNetRudpPacket, Option<u64>)>,
reliable_packets_expired: u64,
/// Every reliable sequence up to and including this one has been received
@ -284,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,
@ -368,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();
}
@ -560,8 +574,14 @@ impl CultNetRudpSession {
payload: Vec<u8>,
options: CultNetRudpSendOptions,
) -> Result<CultNetRudpPacket> {
// 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.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"
@ -1073,22 +1093,47 @@ impl CultNetRudpSession {
self.queued_reliable
.retain(|(_, expires_at_ms)| expires_at_ms.is_none_or(|at| now_ms <= at));
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);
}
/// 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 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
}
fn admit_reliable_packets(
&mut self,
packets: Vec<CultNetRudpPacket>,
now_ms: u64,
expires_at_ms: Option<u64>,
) -> Vec<CultNetRudpPacket> {
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 {
@ -1099,10 +1144,12 @@ impl CultNetRudpSession {
}
fn promote_queued_reliable(&mut self, now_ms: u64) -> Vec<CultNetRudpPacket> {
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;
};
@ -1133,12 +1180,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

View file

@ -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())?;

View file

@ -0,0 +1,310 @@
//! 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 session(initial_sequence: u32) -> CultNetRudpSession {
CultNetRudpSession::new(CultNetRudpSessionOptions {
connection_id: CONNECTION_ID,
initial_sequence: Some(initial_sequence),
resend_delay_ms: 25,
max_pending_reliable_packets: None,
})
}
/// 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<CultNetRudpSession> {
Ok(connected_pair(initial_sequence, 900)?.0)
}
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<Vec<CultNetRudpPacket>> {
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<u32> {
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<CultNetRudpPacket> {
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, 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;
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());
// 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;
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]);
// The bytes above the old gap no longer count: another MiB fits.
assert_eq!(sequences(&send(&mut sender, MIB)?), vec![g.sequence + 6]);
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(())
}
#[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(())
}
#[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(())
}
/// 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(())
}

View file

@ -33,6 +33,11 @@ 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;
/// How long a client keeps retransmitting a Connect nobody answered before it
/// abandons that attempt for a fresh one. A Connect the server answers with an
/// Ack, never an Accept, is one the server judged stale (see `acceptConnect`);
@ -212,6 +217,9 @@ export class CultNetRudpSession {
readonly #receivedSequences = new Set<number>();
readonly #latestSequencedByChannel = new Map<string, number>();
readonly #pendingReliable = new Map<number, 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.
readonly #ackedAboveLowest = new Map<number, number>();
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
@ -294,6 +302,7 @@ export class CultNetRudpSession {
this.#awaitingAccept = false;
this.#generation += 1;
this.#pendingReliable.clear();
this.#ackedAboveLowest.clear();
this.#queuedReliable.splice(0);
}
@ -420,7 +429,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]!;
@ -753,18 +762,44 @@ 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;
}
for (const length of this.#ackedAboveLowest.values()) bytesAbove += length;
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;
}
@ -778,12 +813,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

View file

@ -1417,6 +1417,167 @@ 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 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 {
return connectedFlowPair(initialSequence, 900)[0];
}
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, receiver] = connectedFlowPair(1, 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);
// 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]);
});
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]);
// 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", () => {
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]);
});
// 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 {

View file

@ -885,6 +885,10 @@ namespace GameCult.Networking
{
/// <summary>Maximum reliable packets admitted to the wire before acknowledgements advance the window.</summary>
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;
// How long a client keeps retransmitting a Connect nobody answered before it abandons that
// attempt for a fresh one. A Connect the server answers with an Ack, never an Accept, is one
@ -935,6 +939,9 @@ namespace GameCult.Networking
private readonly Dictionary<string, uint> _latestSequencedByChannel = new Dictionary<string, uint>(StringComparer.Ordinal);
private readonly object _pendingReliableGate = new object();
private readonly Dictionary<uint, PendingReliablePacket> _pendingReliable = new Dictionary<uint, 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.
private readonly Dictionary<uint, int> _ackedAboveLowest = new Dictionary<uint, int>();
private readonly Queue<CultNetRudpPacket> _queuedReliable = new Queue<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 peer's Connect on the accepting side, the
@ -1156,7 +1163,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();
@ -1420,6 +1427,7 @@ namespace GameCult.Networking
lock (_pendingReliableGate)
{
_pendingReliable.Clear();
_ackedAboveLowest.Clear();
_queuedReliable.Clear();
}
}
@ -1573,17 +1581,39 @@ namespace GameCult.Networking
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
foreach (var length in _ackedAboveLowest.Values)
bytesAbove += length;
return sequence - lowest <= FlowWindowSequences && bytesAbove + payloadLength <= FlowWindowBytes;
}
private IReadOnlyList<CultNetRudpPacket> AdmitReliablePackets(
IReadOnlyList<CultNetRudpPacket> packets,
long nowMs)
{
lock (_pendingReliableGate)
{
var available = Math.Max(0, ReliableSendWindowPackets - _pendingReliable.Count);
var ready = new List<CultNetRudpPacket>(Math.Min(available, packets.Count));
var ready = new List<CultNetRudpPacket>();
foreach (var packet in packets)
{
if (ready.Count < available)
if (_queuedReliable.Count == 0 && WindowAdmits(packet.Sequence, packet.Payload.Length))
{
_pendingReliable[packet.Sequence] = new PendingReliablePacket
{
@ -1605,9 +1635,9 @@ namespace GameCult.Networking
{
lock (_pendingReliableGate)
{
var available = Math.Max(0, ReliableSendWindowPackets - _pendingReliable.Count);
var ready = new List<CultNetRudpPacket>(Math.Min(available, _queuedReliable.Count));
while (ready.Count < available && _queuedReliable.Count > 0)
var ready = new List<CultNetRudpPacket>();
while (_queuedReliable.Count > 0
&& WindowAdmits(_queuedReliable.Peek().Sequence, _queuedReliable.Peek().Payload.Length))
{
var packet = _queuedReliable.Dequeue();
_pendingReliable[packet.Sequence] = new PendingReliablePacket
@ -1641,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)

View file

@ -1141,6 +1141,196 @@ 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 FlowSession(uint initialSequence) =>
new CultNetRudpSession(new CultNetRudpSessionOptions
{
ConnectionId = 0x464c4f57,
InitialSequence = 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.
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<CultNetRudpPacket> 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, receiver) = ConnectedFlowPair(1, 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<InvalidOperationException>(() => 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);
// A direct send refuses instead of queueing behind it, and takes no sequence.
Assert.Throws<InvalidOperationException>(() => 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;
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<InvalidOperationException>(() => 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 }));
// 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]
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 }));
}
// 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()
{