Server closes connections gracefully so kick/ban/reset/shutdown messages arrive; status dot and HUD yellow until really connected (also while reconnecting)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 11:00:06 +02:00
co-authored by Claude Opus 5.5
parent 08844b18ac
commit 57e1d3d3a8
3 changed files with 70 additions and 8 deletions
+15 -7
View File
@@ -79,7 +79,7 @@ public sealed class GameServer
public void EnqueueCommand(string line) => commands.Enqueue(line); 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 // ---------------------------------------------------------------- Start / Schleife
@@ -142,8 +142,16 @@ public sealed class GameServer
{ {
// Grund mitschicken: die Spieler sehen ihn im Hauptmenü // 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."))); 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(); listener.Stop();
SaveWorld(); SaveWorld();
if (!offline) if (!offline)
@@ -272,7 +280,7 @@ public sealed class GameServer
w.Write(names.Count); w.Write(names.Count);
foreach (string n in names) w.Write(n); foreach (string n in names) w.Write(n);
})); }));
c.Conn.Close("Info query"); c.Conn.CloseAfterSend("Info query");
return; return;
case MsgType.Hello: case MsgType.Hello:
@@ -404,8 +412,8 @@ public sealed class GameServer
c.SteamVerified = steam.Enabled && !c.SteamId.StartsWith("name:"); c.SteamVerified = steam.Enabled && !c.SteamId.StartsWith("name:");
foreach (var other in clients.Values) foreach (var other in clients.Values)
{ {
if (other == c || other.State != ClientState.Joined || other.SteamId != c.SteamId) continue; if (other == c || other.State != ClientState.Joined || other.Conn.IsClosing || other.SteamId != c.SteamId) continue;
if (c.SteamVerified) other.Conn.Close("Joined from another location"); // echter Account: alte Verbindung trennen 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; } 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) void Reject(Client c, string reason)
{ {
c.Conn.Send(MsgType.Reject, Wire.Build(w => w.Write(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}"); 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()) 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.Send(MsgType.Reconnect, Wire.Build(w => w.Write(message)));
c.Conn.Close("Reconnect after world change"); c.Conn.CloseAfterSend("Reconnect after world change");
count++; count++;
} }
if (count > 0) Log($"{count} player(s) will reconnect"); if (count > 0) Log($"{count} player(s) will reconnect");
+41 -1
View File
@@ -89,6 +89,9 @@ public sealed class Connection
readonly TcpClient client; readonly TcpClient client;
readonly NetworkStream stream; 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]; readonly byte[] readBuffer = new byte[64 * 1024];
byte[] buffer = new byte[64 * 1024]; byte[] buffer = new byte[64 * 1024];
int count; int count;
@@ -106,6 +109,11 @@ public sealed class Connection
{ {
var messages = new List<Message>(); var messages = new List<Message>();
if (IsClosed) return messages; if (IsClosed) return messages;
if (closing)
{
DrainWhileClosing();
return messages;
}
try try
{ {
if (client.Client.Poll(0, SelectMode.SelectRead) && client.Available == 0) 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) public void Send(MsgType type, byte[] payload = null)
{ {
if (IsClosed) return; if (IsClosed || closing) return;
try try
{ {
byte[] frame = Wire.Frame(type, payload); byte[] frame = Wire.Frame(type, payload);
@@ -143,6 +151,38 @@ public sealed class Connection
} }
} }
/// <summary>
/// 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.
/// </summary>
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) public void Close(string reason)
{ {
if (IsClosed) return; if (IsClosed) return;
+14
View File
@@ -155,6 +155,20 @@ try
watcher.Close(); watcher.Close();
await Task.Delay(300); 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"); Console.WriteLine("== reset-world makes everyone reconnect");
var r1 = await TestClient.Join("Resetter", 0, null); var r1 = await TestClient.Join("Resetter", 0, null);
server.EnqueueCommand("reset-world"); server.EnqueueCommand("reset-world");