diff --git a/GameServer.cs b/GameServer.cs index f9f1ce7..8f30918 100644 --- a/GameServer.cs +++ b/GameServer.cs @@ -79,7 +79,7 @@ public sealed class GameServer public void EnqueueCommand(string line) => commands.Enqueue(line); - int JoinedCount => clients.Values.Count(c => c.State == ClientState.Joined); + int JoinedCount => clients.Values.Count(c => c.State == ClientState.Joined && !c.Conn.IsClosing); // gerade getrennte zählen nicht mehr // ---------------------------------------------------------------- Start / Schleife @@ -142,8 +142,16 @@ public sealed class GameServer { // Grund mitschicken: die Spieler sehen ihn im Hauptmenü if (c.State == ClientState.Joined) c.Conn.Send(MsgType.Reject, Wire.Build(w => w.Write("The server was shut down."))); - c.Conn.Close("Server shutting down"); + c.Conn.CloseAfterSend("Server shutting down"); } + // Kurz warten, bis die Spieler die Meldung gelesen haben (sauber trennen statt hart abbrechen) + var waitUntil = DateTime.UtcNow.AddSeconds(1.5); + while (DateTime.UtcNow < waitUntil && clients.Values.Any(c => !c.Conn.IsClosed)) + { + foreach (var c in clients.Values) c.Conn.Poll(); + Thread.Sleep(20); + } + foreach (var c in clients.Values) c.Conn.Close("Server shutting down"); listener.Stop(); SaveWorld(); if (!offline) @@ -272,7 +280,7 @@ public sealed class GameServer w.Write(names.Count); foreach (string n in names) w.Write(n); })); - c.Conn.Close("Info query"); + c.Conn.CloseAfterSend("Info query"); return; case MsgType.Hello: @@ -404,8 +412,8 @@ public sealed class GameServer c.SteamVerified = steam.Enabled && !c.SteamId.StartsWith("name:"); foreach (var other in clients.Values) { - if (other == c || other.State != ClientState.Joined || other.SteamId != c.SteamId) continue; - if (c.SteamVerified) other.Conn.Close("Joined from another location"); // echter Account: alte Verbindung trennen + if (other == c || other.State != ClientState.Joined || other.Conn.IsClosing || other.SteamId != c.SteamId) continue; + if (c.SteamVerified) other.Conn.CloseAfterSend("Joined from another location"); // echter Account: alte Verbindung trennen else { Reject(c, "A player with this identity is already connected"); return; } } @@ -440,7 +448,7 @@ public sealed class GameServer void Reject(Client c, string reason) { c.Conn.Send(MsgType.Reject, Wire.Build(w => w.Write(reason))); - c.Conn.Close(reason); + c.Conn.CloseAfterSend(reason); Log($"Rejected {c.Name} ({c.Conn.RemoteAddress}): {reason}"); } @@ -472,7 +480,7 @@ public sealed class GameServer foreach (var c in clients.Values.Where(x => x.State == ClientState.Joined).ToList()) { c.Conn.Send(MsgType.Reconnect, Wire.Build(w => w.Write(message))); - c.Conn.Close("Reconnect after world change"); + c.Conn.CloseAfterSend("Reconnect after world change"); count++; } if (count > 0) Log($"{count} player(s) will reconnect"); diff --git a/Protocol.cs b/Protocol.cs index 4e6843b..b7eba74 100644 --- a/Protocol.cs +++ b/Protocol.cs @@ -89,6 +89,9 @@ public sealed class Connection readonly TcpClient client; readonly NetworkStream stream; + bool closing; // Meldung gesendet, warten bis der Spieler sie gelesen hat (siehe CloseAfterSend) + DateTime closeDeadline; + string closingReason; readonly byte[] readBuffer = new byte[64 * 1024]; byte[] buffer = new byte[64 * 1024]; int count; @@ -106,6 +109,11 @@ public sealed class Connection { var messages = new List(); if (IsClosed) return messages; + if (closing) + { + DrainWhileClosing(); + return messages; + } try { if (client.Client.Poll(0, SelectMode.SelectRead) && client.Available == 0) @@ -131,7 +139,7 @@ public sealed class Connection public void Send(MsgType type, byte[] payload = null) { - if (IsClosed) return; + if (IsClosed || closing) return; try { byte[] frame = Wire.Frame(type, payload); @@ -143,6 +151,38 @@ public sealed class Connection } } + /// + /// Sauber trennen, nachdem eine letzte Meldung gesendet wurde (Kick/Ban-Grund, Neu-Verbinden, Shutdown). + /// Sofortiges Close() mit noch ungelesenen Daten des Spielers (er schickt ständig Positionen) führt zu einem + /// TCP-Reset - dann geht die gerade gesendete Meldung verloren. Deshalb: nur die Sende-Richtung schließen, + /// eingehende Daten noch kurz lesen und verwerfen, dann erst schließen. + /// + public void CloseAfterSend(string reason) + { + if (IsClosed || closing) return; + closing = true; + closingReason = reason; + closeDeadline = DateTime.UtcNow.AddSeconds(3); + try { client.Client.Shutdown(SocketShutdown.Send); } catch { Close(reason); } + } + + public bool IsClosing => closing && !IsClosed; + + void DrainWhileClosing() + { + try + { + while (stream.DataAvailable) + if (stream.Read(readBuffer, 0, readBuffer.Length) <= 0) break; + bool remoteClosed = client.Client.Poll(0, SelectMode.SelectRead) && client.Available == 0; + if (remoteClosed || DateTime.UtcNow >= closeDeadline) Close(closingReason); + } + catch + { + Close(closingReason); + } + } + public void Close(string reason) { if (IsClosed) return; diff --git a/Tests/Program.cs b/Tests/Program.cs index 50be6a8..04e4d38 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -155,6 +155,20 @@ try watcher.Close(); await Task.Delay(300); + Console.WriteLine("== Kick reason arrives while the player keeps sending"); + var spammer = await TestClient.Join("Spammer", 0, null); + var spamming = true; + var spamTask = Task.Run(async () => { while (spamming) { spammer.Send(MsgType.Player, PlayerPos(1f, 2f, 3f)); await Task.Delay(5); } }); + await Task.Delay(300); + server.EnqueueCommand("kick " + spammer.Id + " testing"); + var kickMsg = await spammer.WaitFor(MsgType.Reject, 3000); + spamming = false; + await spamTask; + Check(kickMsg != null && new BinaryReader(new MemoryStream(kickMsg)).ReadString().Contains("testing"), + "Kicked player receives the reason even while sending data (no hard reset)"); + spammer.Close(); + await Task.Delay(300); + Console.WriteLine("== reset-world makes everyone reconnect"); var r1 = await TestClient.Join("Resetter", 0, null); server.EnqueueCommand("reset-world");