diff --git a/packages/cultnet-py/src/cultnet_py/transport.py b/packages/cultnet-py/src/cultnet_py/transport.py index 61e06da2..5b2976e7 100644 --- a/packages/cultnet-py/src/cultnet_py/transport.py +++ b/packages/cultnet-py/src/cultnet_py/transport.py @@ -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 diff --git a/packages/cultnet-rs/src/rudp.rs b/packages/cultnet-rs/src/rudp.rs index 9952dc8f..450692aa 100644 --- a/packages/cultnet-rs/src/rudp.rs +++ b/packages/cultnet-rs/src/rudp.rs @@ -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, } @@ -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, @@ -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) -> 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 { diff --git a/packages/cultnet-ts/src/rudp.ts b/packages/cultnet-ts/src/rudp.ts index e87c9dd3..c04ab5da 100644 --- a/packages/cultnet-ts/src/rudp.ts +++ b/packages/cultnet-ts/src/rudp.ts @@ -145,6 +145,12 @@ export class CultNetRudpSession { readonly #nextSequencedByChannel = new Map(); #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 { 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; diff --git a/src/GameCult.Networking/CultNetTransport.cs b/src/GameCult.Networking/CultNetTransport.cs index 1a9db1ce..5a325b88 100644 --- a/src/GameCult.Networking/CultNetTransport.cs +++ b/src/GameCult.Networking/CultNetTransport.cs @@ -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 _receivedSequences = new HashSet(); @@ -964,6 +970,8 @@ namespace GameCult.Networking /// Gets whether the session has completed the connect/accept handshake. /// public bool Connected => _connected; + internal long Generation => Interlocked.Read(ref _generation); + internal bool Ended => _ended; /// /// Gets the logical time of the last received packet. /// @@ -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(), 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(), 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 } /// - /// 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. /// - 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(); } + } + + /// + /// Ends the current generation and forgets everything learned from the peer; sequence numbers + /// already issued stay issued. + /// + 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 /// public CultNetRudpPacket CreateDisconnect(byte[]? reason = null) { - _connected = false; + EndSession(); return CreatePacket(CultNetRudpPacketType.Disconnect, "control", reason ?? Array.Empty(), 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(); 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; }