From 4ea4a6f1dc1c0d4adb3f3f2f80b31da08068b7af Mon Sep 17 00:00:00 2001 From: mibac138 <5672750+mibac138@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:10:49 +0100 Subject: [PATCH 1/2] Ensure a goodbye packet is sent when a client is disconnected The only reliable way to actually send a packet before closing the connection in LiteNetLib is to pass it to the Disconnect method. --- Source/Client/Networking/NetworkingInMemory.cs | 3 ++- Source/Client/Networking/NetworkingLiteNet.cs | 5 +++-- Source/Client/Networking/NetworkingSteam.cs | 7 ++++++- Source/Client/Saving/ReplayConnection.cs | 3 ++- Source/Common/Networking/ConnectionBase.cs | 11 ++++++++--- Source/Common/Networking/LiteNetConnection.cs | 9 ++++++--- 6 files changed, 27 insertions(+), 11 deletions(-) diff --git a/Source/Client/Networking/NetworkingInMemory.cs b/Source/Client/Networking/NetworkingInMemory.cs index 5f47279b..2c8b70fc 100644 --- a/Source/Client/Networking/NetworkingInMemory.cs +++ b/Source/Client/Networking/NetworkingInMemory.cs @@ -1,5 +1,6 @@ using System; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; using Verse; namespace Multiplayer.Client.Networking @@ -46,7 +47,7 @@ protected override void SendRaw(byte[] raw, bool reliable = true) }); } - protected override void OnClose() + protected override void OnClose(ServerDisconnectPacket? goodbye) { } diff --git a/Source/Client/Networking/NetworkingLiteNet.cs b/Source/Client/Networking/NetworkingLiteNet.cs index 12fbc4b1..4aa7d3dc 100644 --- a/Source/Client/Networking/NetworkingLiteNet.cs +++ b/Source/Client/Networking/NetworkingLiteNet.cs @@ -4,6 +4,7 @@ using LiteNetLib; using Multiplayer.Client.Util; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; using Verse; namespace Multiplayer.Client.Networking @@ -49,9 +50,9 @@ public void OnDisconnect(MpDisconnectReason reason, ByteReader data) Multiplayer.StopMultiplayer(); } - protected override void OnClose() + protected override void OnClose(ServerDisconnectPacket? goodbye) { - base.OnClose(); + base.OnClose(goodbye); netManager.Stop(); } diff --git a/Source/Client/Networking/NetworkingSteam.cs b/Source/Client/Networking/NetworkingSteam.cs index 90192f5c..91a5c12c 100644 --- a/Source/Client/Networking/NetworkingSteam.cs +++ b/Source/Client/Networking/NetworkingSteam.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Linq; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; using Steamworks; using Verse; using Verse.Steam; @@ -41,8 +42,12 @@ public void SendRawSteam(byte[] raw, bool reliable) public abstract void OnError(EP2PSessionError error); - protected override void OnClose() + protected override void OnClose(ServerDisconnectPacket? goodbye) { + if (goodbye.HasValue) Send(goodbye.Value); + // TODO this should probably include SteamNetworking.CloseP2PSessionWithUser to free up any leftover + // resources in the Steam API. The API docs are not clear whether the connection is closed instantly, or + // are the queued packets sent. } public override string ToString() => $"SteamP2P ({remoteId}:{username})"; diff --git a/Source/Client/Saving/ReplayConnection.cs b/Source/Client/Saving/ReplayConnection.cs index 4dfc691f..cae5d531 100644 --- a/Source/Client/Saving/ReplayConnection.cs +++ b/Source/Client/Saving/ReplayConnection.cs @@ -1,5 +1,6 @@ using System; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; namespace Multiplayer.Client; @@ -30,7 +31,7 @@ public override void HandleReceiveRaw(ByteReader data, bool reliable) { } - protected override void OnClose() + protected override void OnClose(ServerDisconnectPacket? goodbye) { } } diff --git a/Source/Common/Networking/ConnectionBase.cs b/Source/Common/Networking/ConnectionBase.cs index 54151124..61f59a1d 100644 --- a/Source/Common/Networking/ConnectionBase.cs +++ b/Source/Common/Networking/ConnectionBase.cs @@ -266,13 +266,18 @@ private void ExecuteMessageHandler(PacketHandlerInfo handler, Packets packet, By public void Close(MpDisconnectReason reason, byte[]? data = null) { + // Ideally, we'd send the final packet here and let OnClose handle only the connection teardown. + // However, LiteNetLib closes connections immediately, discarding any queued packets. + // To ensure reliable delivery before closing, data must be sent via the Disconnect method itself. + // State.IsServer check only used when disconnecting from a self-hosted local server if (State != ConnectionStateEnum.Disconnected && State.IsServer()) - Send(new ServerDisconnectPacket { reason = reason, data = data ?? [] }); - OnClose(); + OnClose(new ServerDisconnectPacket { reason = reason, data = data ?? [] }); + else + OnClose(null); } - protected abstract void OnClose(); + protected abstract void OnClose(ServerDisconnectPacket? goodbye); /// Invoked after a keep alive timer arrives. Only used by the server public virtual void OnKeepAliveArrived(bool idMatched) diff --git a/Source/Common/Networking/LiteNetConnection.cs b/Source/Common/Networking/LiteNetConnection.cs index 633b4fdb..005aace4 100644 --- a/Source/Common/Networking/LiteNetConnection.cs +++ b/Source/Common/Networking/LiteNetConnection.cs @@ -1,4 +1,5 @@ using LiteNetLib; +using Multiplayer.Common.Networking.Packet; namespace Multiplayer.Common { @@ -14,10 +15,12 @@ protected override void SendRaw(byte[] raw, bool reliable) ServerLog.Error($"SendRaw() called with invalid connection state ({peer}): {peer.ConnectionState}"); } - protected override void OnClose() + protected override void OnClose(ServerDisconnectPacket? goodbye) { - peer.NetManager.TriggerUpdate(); // todo: is this needed? - peer.NetManager.DisconnectPeer(peer); + if (goodbye.HasValue) + peer.Disconnect(GetDisconnectBytes(goodbye.Value.reason, goodbye.Value.data)); + else + peer.Disconnect(); } public override void OnKeepAliveArrived(bool idMatched) From 0442adf0f1656ec08070248bfc0756bb423caca4 Mon Sep 17 00:00:00 2001 From: mibac138 <5672750+mibac138@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:38:10 +0100 Subject: [PATCH 2/2] Remove GetDisconnectBytes in favor of ServerDisconnectPacket --- Source/Client/Networking/NetworkingLiteNet.cs | 9 ++++----- Source/Client/Networking/State/ClientBaseState.cs | 4 ++-- Source/Client/Session/SessionDisconnectInfo.cs | 4 ++++ Source/Common/Networking/ConnectionBase.cs | 8 -------- Source/Common/Networking/LiteNetConnection.cs | 2 +- Source/Common/Networking/NetworkingLiteNet.cs | 6 +++--- 6 files changed, 14 insertions(+), 19 deletions(-) diff --git a/Source/Client/Networking/NetworkingLiteNet.cs b/Source/Client/Networking/NetworkingLiteNet.cs index 4aa7d3dc..ba76ccfa 100644 --- a/Source/Client/Networking/NetworkingLiteNet.cs +++ b/Source/Client/Networking/NetworkingLiteNet.cs @@ -43,10 +43,10 @@ public static ClientLiteNetConnection Connect(string address, int port) public void Tick() => netManager.PollEvents(); - public void OnDisconnect(MpDisconnectReason reason, ByteReader data) + public void OnDisconnect(SessionDisconnectInfo disconnectInfo) { if (State == ConnectionStateEnum.Disconnected) return; - ConnectionStatusListeners.TryNotifyAll_Disconnected(SessionDisconnectInfo.From(reason, data)); + ConnectionStatusListeners.TryNotifyAll_Disconnected(disconnectInfo); Multiplayer.StopMultiplayer(); } @@ -83,8 +83,7 @@ public void OnPeerDisconnected(NetPeer peer, DisconnectInfo info) MpDisconnectReason reason; ByteReader reader; - // Fallback: should generally be handled by ClientBaseState.HandleDisconnected. - if (info.AdditionalData.IsNull || info.AdditionalData.AvailableBytes == 0) + if (info.AdditionalData.EndOfData) { if (info.Reason is DisconnectReason.DisconnectPeerCalled or DisconnectReason.RemoteConnectionClose) reason = MpDisconnectReason.Generic; @@ -101,7 +100,7 @@ public void OnPeerDisconnected(NetPeer peer, DisconnectInfo info) reason = reader.ReadEnum(); } - GetConnection(peer).OnDisconnect(reason, reader); + GetConnection(peer).OnDisconnect(SessionDisconnectInfo.From(reason, reader)); MpLog.Log($"Net client disconnected {info.Reason}"); } diff --git a/Source/Client/Networking/State/ClientBaseState.cs b/Source/Client/Networking/State/ClientBaseState.cs index 4377aa2c..8ca077b8 100644 --- a/Source/Client/Networking/State/ClientBaseState.cs +++ b/Source/Client/Networking/State/ClientBaseState.cs @@ -28,11 +28,11 @@ public void HandleTimeControl(ServerTimeControlPacket packet) Multiplayer.session.ProcessTimeControl(); } + // Currently handles disconnection only for Steam connections. See comment in ConnectionBase.Close for more info. [TypedPacketHandler] public void HandleDisconnected(ServerDisconnectPacket packet) { - ConnectionStatusListeners.TryNotifyAll_Disconnected(SessionDisconnectInfo.From(packet.reason, - new ByteReader(packet.data))); + ConnectionStatusListeners.TryNotifyAll_Disconnected(SessionDisconnectInfo.From(packet)); Multiplayer.StopMultiplayer(); } } diff --git a/Source/Client/Session/SessionDisconnectInfo.cs b/Source/Client/Session/SessionDisconnectInfo.cs index 05f42e03..5b69caef 100644 --- a/Source/Client/Session/SessionDisconnectInfo.cs +++ b/Source/Client/Session/SessionDisconnectInfo.cs @@ -1,6 +1,7 @@ using System; using LiteNetLib; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; using Verse; namespace Multiplayer.Client; @@ -13,6 +14,9 @@ public struct SessionDisconnectInfo public Action specialButtonAction; public bool wideWindow; + public static SessionDisconnectInfo From(ServerDisconnectPacket goodbye) => + From(goodbye.reason, new ByteReader(goodbye.data)); + public static SessionDisconnectInfo From(MpDisconnectReason reason, ByteReader reader) { var disconnectInfo = new SessionDisconnectInfo(); diff --git a/Source/Common/Networking/ConnectionBase.cs b/Source/Common/Networking/ConnectionBase.cs index 61f59a1d..a75d3010 100644 --- a/Source/Common/Networking/ConnectionBase.cs +++ b/Source/Common/Networking/ConnectionBase.cs @@ -283,13 +283,5 @@ public void Close(MpDisconnectReason reason, byte[]? data = null) public virtual void OnKeepAliveArrived(bool idMatched) { } - - public static byte[] GetDisconnectBytes(MpDisconnectReason reason, byte[]? data = null) - { - var writer = new ByteWriter(); - writer.WriteEnum(reason); - writer.WriteRaw(data ?? []); - return writer.ToArray(); - } } } diff --git a/Source/Common/Networking/LiteNetConnection.cs b/Source/Common/Networking/LiteNetConnection.cs index 005aace4..b3a8c5d0 100644 --- a/Source/Common/Networking/LiteNetConnection.cs +++ b/Source/Common/Networking/LiteNetConnection.cs @@ -18,7 +18,7 @@ protected override void SendRaw(byte[] raw, bool reliable) protected override void OnClose(ServerDisconnectPacket? goodbye) { if (goodbye.HasValue) - peer.Disconnect(GetDisconnectBytes(goodbye.Value.reason, goodbye.Value.data)); + peer.Disconnect(goodbye.Value.Serialize().data); else peer.Disconnect(); } diff --git a/Source/Common/Networking/NetworkingLiteNet.cs b/Source/Common/Networking/NetworkingLiteNet.cs index faed7396..374d848e 100644 --- a/Source/Common/Networking/NetworkingLiteNet.cs +++ b/Source/Common/Networking/NetworkingLiteNet.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Sockets; using LiteNetLib; +using Multiplayer.Common.Networking.Packet; namespace Multiplayer.Common { @@ -8,10 +9,9 @@ public class MpServerNetListener(MultiplayerServer server, bool arbiter) : INetE { public void OnConnectionRequest(ConnectionRequest req) { - var result = server.playerManager.OnPreConnect(req.RemoteEndPoint.Address); - if (result != null) + if (server.playerManager.OnPreConnect(req.RemoteEndPoint.Address) is { } disconnectReason) { - req.Reject(ConnectionBase.GetDisconnectBytes(result.Value)); + req.Reject(new ServerDisconnectPacket { reason = disconnectReason }.Serialize().data); return; }