Ack Cut 1b: every ending drops what it owed; a flush is bound to its generation

Each session gets one private primitive that ends its current generation:
not connected, pending and queued writes dropped, generation advanced,
"ended" raised until the next Connect or Accept begins one. A peer's
Disconnect, a local disconnect, a timeout and a reset (refusal included) all
call it, and it leaves what was received from the peer alone. The socket
transports' flush captures the session generation and fails when it changes
or the session has ended, in all four runtimes; the disconnect reason and,
in TypeScript, the transport's generation pair are report-only or gone. Rust
receipts carry the generation in place of the session scope UUID, so a
receipt reads Invalidated after any ending and stays so after a reconnect.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 00:13:22 +02:00
commit ebc7080165
4 changed files with 185 additions and 82 deletions

View file

@ -415,6 +415,13 @@ class CultNetRudpSession:
self._next_sequenced_by_channel: dict[str, int] = {}
self._next_fragment_id = 1
self._connected = False
# Advances every time a generation ends. Everything issued in a
# generation (writes, flushes) belongs to it and dies with it.
self._generation = 0
# True from the end of a generation until the next Connect or Accept
# begins one. A flush started in that interval has no live generation
# to wait on.
self._ended = False
self._last_received_at_ms: int | None = None
self._highest_received_sequence: int | None = None
self._received_sequences: set[int] = set()
@ -462,16 +469,35 @@ class CultNetRudpSession:
def outstanding_reliable_packet_count(self) -> int:
return len(self._pending_reliable) + len(self._queued_reliable)
def reset_peer_state(self) -> None:
"""Forgets everything learned from the peer; sequence numbers already issued stay issued."""
@property
def generation(self) -> int:
return self._generation
@property
def ended(self) -> bool:
return self._ended
def _end_session(self) -> None:
"""The one way a session generation ends. The session stops being
connected and what it still owed the peer dies with it: a write not yet
acknowledged is dropped, so no later session retransmits it or credits
an ack to it. What was learned from the peer is not touched: the peer
may not know the session ended, and forgetting what it sent would let its
retransmits be delivered twice."""
self._connected = False
self._ended = True
self._generation += 1
self._pending_reliable.clear()
self._queued_reliable.clear()
def reset_peer_state(self) -> None:
"""Ends the current generation and forgets everything learned from the peer; sequence numbers already issued stay issued."""
self._end_session()
self._last_received_at_ms = None
self._highest_received_sequence = None
self._received_sequences.clear()
self._next_sequenced_by_channel.clear()
self._latest_sequenced_by_channel.clear()
self._pending_reliable.clear()
self._queued_reliable.clear()
self._received_through = None
self._ordered_held.clear()
self._fragment_buffers.clear()
@ -479,6 +505,7 @@ class CultNetRudpSession:
def create_connect(self, now_ms: int = 0, payload: bytes = b"") -> CultNetRudpPacket:
self._ensure_reliable_capacity(1)
self._ended = False
packet = self._create_packet(
CultNetRudpPacketType.CONNECT,
"control",
@ -502,6 +529,7 @@ class CultNetRudpSession:
self._ensure_reliable_capacity(1)
self._remember_received(packet.sequence)
self._connected = True
self._ended = False
response = self._create_packet(
CultNetRudpPacketType.ACCEPT,
"control",
@ -619,7 +647,7 @@ class CultNetRudpSession:
)
if packet.packet_type == CultNetRudpPacketType.DISCONNECT:
self._connected = False
self._end_session()
return CultNetRudpReceiveResult(
ready_to_send=ready_to_send,
disconnected=True,
@ -696,7 +724,7 @@ class CultNetRudpSession:
return self._create_packet(CultNetRudpPacketType.PING, "control", payload)
def create_disconnect(self, reason: bytes = b"") -> CultNetRudpPacket:
self._connected = False
self._end_session()
return self._create_packet(CultNetRudpPacketType.DISCONNECT, "control", reason)
def check_timeout(self, now_ms: int, timeout_ms: int) -> bool:
@ -704,7 +732,7 @@ class CultNetRudpSession:
return False
if now_ms - self._last_received_at_ms <= timeout_ms:
return False
self._connected = False
self._end_session()
return True
def due_resends(self, now_ms: int) -> tuple[CultNetRudpPacket, ...]:
@ -1069,14 +1097,19 @@ class CultNetRudpSocketTransportConnection:
preserved = deque(self._delivered_frames)
self._delivered_frames.clear()
self.socket.settimeout(poll_timeout)
# A flush belongs to the generation it started in: whatever ends that
# generation, and whatever begins after it, the writes being waited on
# are gone.
generation = self.session.generation
try:
while True:
# An ended session forgot its unacknowledged writes; reporting
# them flushed would be a lie.
if self.disconnect_reason is not None:
if self.session.ended or self.session.generation != generation:
reason = self.disconnect_reason
raise ConnectionError(
"RUDP session ended before its reliable writes were acknowledged: "
+ self.disconnect_reason.decode("utf-8", errors="replace")
+ ("no reason given" if reason is None else reason.decode("utf-8", errors="replace"))
)
if self.session.outstanding_reliable_packet_count == 0:
return

View file

@ -8,7 +8,6 @@ use std::net::SocketAddr;
use std::net::UdpSocket;
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use uuid::Uuid;
use crate::CultNetMessage;
use crate::CultNetReconnectController;
@ -126,13 +125,12 @@ pub struct CultNetRudpSendOptions {
}
/// Identifies every transport packet belonging to one non-expiring reliable
/// send. Receipts are local to the session that issued them: a peer reset or
/// a Disconnect received from the peer invalidates them for good. A local
/// `disconnect()` or a timeout does not; the writes it leaves owed carry into
/// the next session.
/// send. A write belongs to the session generation it was issued in, and every
/// way a generation can end (a Disconnect from the peer, a local `disconnect()`,
/// a timeout, a refused packet) invalidates the receipts it issued for good.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CultNetRudpReliableSendReceipt {
session_scope: Uuid,
generation: u64,
sequences: Vec<u32>,
}
@ -170,7 +168,12 @@ struct FragmentBuffer {
}
pub struct CultNetRudpSession {
session_scope: Uuid,
/// Advances every time a generation ends. Everything issued in a generation
/// (writes, receipts, flushes) belongs to it and dies with it.
generation: u64,
/// True from the end of a generation until the next Connect or Accept begins
/// one. A flush started in that interval has no live generation to wait on.
ended: bool,
connection_id: u32,
resend_delay_ms: u64,
max_pending_reliable_packets: Option<usize>,
@ -204,7 +207,8 @@ pub struct CultNetRudpSession {
impl CultNetRudpSession {
pub fn new(options: CultNetRudpSessionOptions) -> Self {
Self {
session_scope: Uuid::new_v4(),
generation: 0,
ended: false,
connection_id: options.connection_id,
resend_delay_ms: options.resend_delay_ms,
max_pending_reliable_packets: options.max_pending_reliable_packets,
@ -243,6 +247,7 @@ impl CultNetRudpSession {
pub fn assume_connected(&mut self, now_ms: u64) {
self.connected = true;
self.ended = false;
self.last_received_at_ms = Some(now_ms);
}
@ -281,15 +286,34 @@ impl CultNetRudpSession {
Some(pending.packet.clone())
}
fn session_scope(&self) -> Uuid {
self.session_scope
fn generation(&self) -> u64 {
self.generation
}
fn ended(&self) -> bool {
self.ended
}
/// The one way a session generation ends. The session stops being
/// connected and what it still owed the peer dies with it: a write not yet
/// acknowledged is dropped, so no later session retransmits it or credits an
/// ack to it, and its receipt can never become Acknowledged. What was
/// learned from the peer is not touched: the peer may not know the session
/// ended, and forgetting what it sent would let its retransmits be
/// delivered twice.
fn end_session(&mut self) {
self.connected = false;
self.ended = true;
self.generation += 1;
self.pending_reliable.clear();
self.queued_reliable.clear();
}
fn reliable_send_status(
&self,
receipt: &CultNetRudpReliableSendReceipt,
) -> CultNetRudpReliableSendStatus {
if receipt.session_scope != self.session_scope {
if receipt.generation != self.generation {
return CultNetRudpReliableSendStatus::Invalidated;
}
if receipt
@ -313,7 +337,7 @@ impl CultNetRudpSession {
));
}
Ok(CultNetRudpReliableSendReceipt {
session_scope: self.session_scope,
generation: self.generation,
sequences: packets.iter().map(|packet| packet.sequence).collect(),
})
}
@ -343,15 +367,12 @@ impl CultNetRudpSession {
/// ids already issued stay issued: a peer that still remembers them must
/// never see one reused.
pub fn reset_peer_state(&mut self) {
self.session_scope = Uuid::new_v4();
self.end_session();
self.next_sequenced_by_channel.clear();
self.connected = false;
self.last_received_at_ms = None;
self.highest_received_sequence = None;
self.received_sequences.clear();
self.latest_sequenced_by_channel.clear();
self.pending_reliable.clear();
self.queued_reliable.clear();
self.received_through = None;
self.ordered_held.clear();
self.fragment_buffers.clear();
@ -377,6 +398,7 @@ impl CultNetRudpSession {
true,
false,
);
self.ended = false;
self.track_reliable(packet.clone(), now_ms, None);
Ok(packet)
}
@ -399,6 +421,7 @@ impl CultNetRudpSession {
self.remember_received(packet.sequence);
self.last_received_at_ms = Some(now_ms);
self.connected = true;
self.ended = false;
let response = self.create_packet(
CultNetRudpPacketType::Accept,
"control",
@ -591,13 +614,7 @@ impl CultNetRudpSession {
}
if packet.packet_type == CultNetRudpPacketType::Disconnect {
// The session is over. What it still owed the peer dies with it,
// and receipts it issued can never become Acknowledged in a later
// session.
self.connected = false;
self.session_scope = Uuid::new_v4();
self.pending_reliable.clear();
self.queued_reliable.clear();
self.end_session();
return Ok(CultNetRudpReceiveResult {
delivered: Vec::new(),
ready_to_send,
@ -760,7 +777,7 @@ impl CultNetRudpSession {
}
pub fn create_disconnect(&mut self, reason: Vec<u8>) -> CultNetRudpPacket {
self.connected = false;
self.end_session();
self.create_packet(
CultNetRudpPacketType::Disconnect,
"control",
@ -781,7 +798,7 @@ impl CultNetRudpSession {
if now_ms.saturating_sub(last_received_at_ms) <= timeout_ms {
return false;
}
self.connected = false;
self.end_session();
true
}
@ -1909,18 +1926,19 @@ impl CultNetRudpSocketTransportConnection {
self.socket.set_read_timeout(Some(poll_timeout))?;
let deadline = Instant::now() + timeout;
// A flush belongs to the session it started in: a Connect that
// replaces the peer mid-wait forgets the writes being waited on.
let scope = self.session.session_scope();
// A flush belongs to the generation it started in: whatever ends that
// generation, and whatever begins after it, the writes being waited on
// are gone.
let generation = self.session.generation();
let mut preserved_frames = VecDeque::new();
let result = (|| {
loop {
// An ended session forgot its unacknowledged writes; reporting
// them flushed would be a lie.
if self.session.session_scope() != scope || self.disconnect_reason.is_some() {
if self.session.ended() || self.session.generation() != generation {
return Err(anyhow!(
"RUDP session ended before its reliable writes were acknowledged: {}",
String::from_utf8_lossy(self.disconnect_reason.as_deref().unwrap_or(b"replaced by a new Connect"))
String::from_utf8_lossy(self.disconnect_reason.as_deref().unwrap_or(b"no reason given"))
));
}
if self.outstanding_reliable_packet_count() == 0 {

View file

@ -145,6 +145,12 @@ export class CultNetRudpSession {
readonly #nextSequencedByChannel = new Map<string, number>();
#nextFragmentId = 1;
#connected = false;
/// Advances every time a generation ends. Everything issued in a generation
/// (writes, flushes) belongs to it and dies with it.
#generation = 0;
/// True from the end of a generation until the next Connect or Accept begins
/// one. A flush started in that interval has no live generation to wait on.
#ended = false;
readonly #maxPendingReliablePackets: number | undefined;
#lastReceivedAtMs: number | undefined;
#highestReceivedSequence: number | undefined;
@ -213,15 +219,37 @@ export class CultNetRudpSession {
return this.#lastReceivedAtMs;
}
resetPeerState(): void {
get generation(): number {
return this.#generation;
}
get ended(): boolean {
return this.#ended;
}
/// The one way a session generation ends. The session stops being connected
/// and what it still owed the peer dies with it: a write not yet acknowledged
/// is dropped, so no later session retransmits it or credits an ack to it.
/// What was learned from the peer is not touched: the peer may not know the
/// session ended, and forgetting what it sent would let its retransmits be
/// delivered twice.
#endSession(): void {
this.#connected = false;
this.#ended = true;
this.#generation += 1;
this.#pendingReliable.clear();
this.#queuedReliable.splice(0);
}
/// Ends the current generation and forgets everything learned from the peer;
/// sequence numbers already issued stay issued.
resetPeerState(): void {
this.#endSession();
this.#lastReceivedAtMs = undefined;
this.#highestReceivedSequence = undefined;
this.#receivedSequences.clear();
this.#nextSequencedByChannel.clear();
this.#latestSequencedByChannel.clear();
this.#pendingReliable.clear();
this.#queuedReliable.splice(0);
this.#receivedThrough = undefined;
this.#orderedHeld.clear();
this.#fragmentBuffers.clear();
@ -230,6 +258,7 @@ export class CultNetRudpSession {
createConnect(nowMs = 0, payload = new Uint8Array()): CultNetRudpPacket {
this.#ensureReliableCapacity(1);
this.#ended = false;
const packet = this.#createPacket({
packetType: "connect",
channelId: "control",
@ -255,6 +284,7 @@ export class CultNetRudpSession {
this.#rememberReceived(packet.sequence);
this.#lastReceivedAtMs = nowMs;
this.#connected = true;
this.#ended = false;
const response = this.#createPacket({
packetType: "accept",
channelId: "control",
@ -403,7 +433,7 @@ export class CultNetRudpSession {
}
if (packet.packetType === "disconnect") {
this.#connected = false;
this.#endSession();
return {
delivered: [],
readyToSend,
@ -504,7 +534,7 @@ export class CultNetRudpSession {
}
createDisconnect(reason = new Uint8Array()): CultNetRudpPacket {
this.#connected = false;
this.#endSession();
return this.#createPacket({
packetType: "disconnect",
channelId: "control",
@ -519,7 +549,7 @@ export class CultNetRudpSession {
if (nowMs - this.#lastReceivedAtMs <= timeoutMs) {
return false;
}
this.#connected = false;
this.#endSession();
return true;
}
@ -777,12 +807,8 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement
#remoteHost: string | undefined;
#remotePort: number | undefined;
#closed = false;
// A flush belongs to the session it started in. Each session start advances
// the generation; each session end records the generation it ended and why.
// A reconnect from a disconnect listener starts a new generation but cannot
// un-end the old one.
#generation = 0;
#endedGeneration = -1;
// Why the last generation ended. A report only: the session owns whether it
// ended.
#endedReason: Uint8Array | undefined;
readonly #stats: CultNetTransportStats = {
bytesReceived: 0,
@ -845,7 +871,6 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement
if (this.#mode !== "client") {
throw new Error("Only a client RUDP socket transport can initiate connect.");
}
this.#generation += 1;
this.#sendPacket(this.#session.createConnect(Date.now(), payload));
}
@ -863,11 +888,14 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement
async flush(timeoutMs = 30_000): Promise<void> {
const deadline = Date.now() + Math.max(0, timeoutMs);
const generation = this.#generation;
// A flush belongs to the generation it started in: whatever ends that
// generation, and whatever begins after it (a reconnect from a disconnect
// listener included), the writes being waited on are gone.
const generation = this.#session.generation;
for (;;) {
// An ended session forgot its unacknowledged writes; reporting them
// flushed would be a lie, even if a new session has since begun.
if (this.#endedGeneration >= generation) {
// flushed would be a lie.
if (this.#session.ended || this.#session.generation !== generation) {
throw new Error(
`RUDP session ended before its reliable writes were acknowledged: ${Buffer.from(this.#endedReason ?? new Uint8Array()).toString("utf8")}`,
);
@ -887,11 +915,6 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement
}
}
#endGeneration(reason: Uint8Array): void {
this.#endedGeneration = this.#generation;
this.#endedReason = reason;
}
ping(payload = new Uint8Array()): void {
this.#sendPacket(this.#session.createPing(payload));
}
@ -899,7 +922,7 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement
checkTimeout(timeoutMs: number, nowMs = Date.now()): boolean {
const timedOut = this.#session.checkTimeout(nowMs, timeoutMs);
if (timedOut) {
this.#endGeneration(Buffer.from("session timed out", "utf8"));
this.#endedReason = Buffer.from("session timed out", "utf8");
this.emit("timeout");
this.emit("close");
}
@ -926,7 +949,7 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement
// The goodbye is built after the reset, or its ack field would acknowledge
// the very frame the session refused.
this.#session.resetPeerState();
this.#endGeneration(reason);
this.#endedReason = reason;
try {
this.#sendPacket(this.#session.createDisconnect(reason));
} catch {
@ -988,15 +1011,14 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement
if (!repeated) {
// A timed-out session already ended its generation; any other
// predecessor ends here, connected or not.
if (this.#endedGeneration < this.#generation) {
this.#endGeneration(Buffer.from("replaced by a new Connect", "utf8"));
if (!this.#session.ended) {
this.#endedReason = Buffer.from("replaced by a new Connect", "utf8");
}
this.#session.resetPeerState();
}
let accept: CultNetRudpPacket;
try {
accept = this.#session.acceptConnect(packet, Date.now());
if (!repeated) this.#generation += 1;
} catch {
this.#stats.packetsDropped += 1;
return;
@ -1034,7 +1056,7 @@ export class CultNetRudpSocketTransportConnection extends EventEmitter implement
} satisfies CultNetTransportFrame);
}
if (result.disconnected) {
this.#endGeneration(result.disconnectReason ?? new Uint8Array());
this.#endedReason = result.disconnectReason ?? new Uint8Array();
this.emit("disconnect", { reason: result.disconnectReason ?? new Uint8Array() });
this.emit("close");
return;

View file

@ -902,6 +902,12 @@ namespace GameCult.Networking
private ushort _nextFragmentId = 1;
private readonly int? _maxPendingReliablePackets;
private volatile bool _connected;
// Advances every time a generation ends. Everything issued in a generation (writes, flushes)
// belongs to it and dies with it.
private long _generation;
// True from the end of a generation until the next Connect or Accept begins one. A flush started
// in that interval has no live generation to wait on.
private bool _ended;
private long? _lastReceivedAtMs;
private uint? _highestReceivedSequence;
private readonly HashSet<uint> _receivedSequences = new HashSet<uint>();
@ -964,6 +970,8 @@ namespace GameCult.Networking
/// Gets whether the session has completed the connect/accept handshake.
/// </summary>
public bool Connected => _connected;
internal long Generation => Interlocked.Read(ref _generation);
internal bool Ended => _ended;
/// <summary>
/// Gets the logical time of the last received packet.
/// </summary>
@ -1000,6 +1008,7 @@ namespace GameCult.Networking
public CultNetRudpPacket CreateConnect(long nowMs = 0, byte[]? payload = null)
{
EnsureReliableCapacity(1);
_ended = false;
var packet = CreatePacket(CultNetRudpPacketType.Connect, "control", payload ?? Array.Empty<byte>(), reliable: true, ordered: true, sequenced: false);
TrackReliable(packet, nowMs);
return packet;
@ -1019,6 +1028,7 @@ namespace GameCult.Networking
EnsureReliableCapacity(1);
RememberReceived(packet.Sequence);
_connected = true;
_ended = false;
var response = CreatePacket(CultNetRudpPacketType.Accept, "control", payload ?? Array.Empty<byte>(), reliable: true, ordered: true, sequenced: false);
TrackReliable(response, nowMs);
return response;
@ -1185,7 +1195,7 @@ namespace GameCult.Networking
if (packet.PacketType == CultNetRudpPacketType.Disconnect)
{
_connected = false;
EndSession();
return new CultNetRudpReceiveResult
{
ReadyToSend = readyToSend,
@ -1287,21 +1297,36 @@ namespace GameCult.Networking
}
/// <summary>
/// Forgets everything learned from the peer; sequence numbers already issued stay issued.
/// The one way a session generation ends. The session stops being connected and what it still
/// owed the peer dies with it: a write not yet acknowledged is dropped, so no later session
/// retransmits it or credits an ack to it. What was learned from the peer is not touched: the
/// peer may not know the session ended, and forgetting what it sent would let its retransmits
/// be delivered twice.
/// </summary>
public void ResetPeerState()
private void EndSession()
{
_connected = false;
_lastReceivedAtMs = null;
_highestReceivedSequence = null;
_receivedSequences.Clear();
_nextSequencedByChannel.Clear();
_latestSequencedByChannel.Clear();
_ended = true;
Interlocked.Increment(ref _generation);
lock (_pendingReliableGate)
{
_pendingReliable.Clear();
_queuedReliable.Clear();
}
}
/// <summary>
/// Ends the current generation and forgets everything learned from the peer; sequence numbers
/// already issued stay issued.
/// </summary>
public void ResetPeerState()
{
EndSession();
_lastReceivedAtMs = null;
_highestReceivedSequence = null;
_receivedSequences.Clear();
_nextSequencedByChannel.Clear();
_latestSequencedByChannel.Clear();
_receivedThrough = null;
_orderedHeld.Clear();
_fragmentBuffers.Clear();
@ -1323,7 +1348,7 @@ namespace GameCult.Networking
/// </summary>
public CultNetRudpPacket CreateDisconnect(byte[]? reason = null)
{
_connected = false;
EndSession();
return CreatePacket(CultNetRudpPacketType.Disconnect, "control", reason ?? Array.Empty<byte>(), reliable: false, ordered: false, sequenced: false);
}
@ -1340,7 +1365,7 @@ namespace GameCult.Networking
{
return false;
}
_connected = false;
EndSession();
return true;
}
@ -1878,18 +1903,23 @@ namespace GameCult.Networking
var preserved = new List<CultNetTransportFrame>();
while (_deliveredFrames.Count > 0)
preserved.Add(_deliveredFrames.Dequeue());
// A flush belongs to the generation it started in: whatever ends that generation, and
// whatever begins after it, the writes being waited on are gone.
long generation;
lock (_sessionGate)
generation = _session.Generation;
try
{
while (true)
{
// An ended session forgot its unacknowledged writes; reporting them
// flushed would be a lie.
var ended = DisconnectReason;
if (ended != null)
throw new InvalidOperationException(
"RUDP session ended before its reliable writes were acknowledged: " + Encoding.UTF8.GetString(ended));
lock (_sessionGate)
{
// An ended session forgot its unacknowledged writes; reporting them flushed
// would be a lie.
if (_session.Ended || _session.Generation != generation)
throw new InvalidOperationException(
"RUDP session ended before its reliable writes were acknowledged: " +
(DisconnectReason == null ? "no reason given" : Encoding.UTF8.GetString(DisconnectReason)));
if (_session.OutstandingReliablePacketCount == 0)
return;
}