diff --git a/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs b/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs index 770dbe2..8c5a2a0 100644 --- a/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs +++ b/SVSim.BattleNode/Hosting/BattleNodeWebSocketHandler.cs @@ -1,3 +1,5 @@ +using System.Net.WebSockets; +using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using SVSim.BattleNode.Bridge; @@ -172,6 +174,7 @@ public sealed class BattleNodeWebSocketHandler "PvP waiting-room timeout or race on BattleId={Bid}; first arriver disconnected.", battleId); _store.RemovePending(battleId); + await TryPoliteCloseAsync(ws, "waiting-room timeout", battleId); return; } // Retry succeeded — we're the de-facto second arriver now. Own the session. @@ -209,6 +212,7 @@ public sealed class BattleNodeWebSocketHandler default: _log.LogError("Unknown BattleType={Type} for BattleId={Bid}; closing WS", pending.Type, battleId); + await TryPoliteCloseAsync(ws, $"unknown BattleType={pending.Type}", battleId); return; } } @@ -219,4 +223,37 @@ public sealed class BattleNodeWebSocketHandler if (!string.IsNullOrEmpty(header)) return header; return ctx.Request.Query[name].ToString(); } + + /// + /// Emit an EIO 1 (Close) text frame, then run the WebSocket close handshake with + /// . Without the EIO frame, BestHTTP / + /// socket.io-client log the disconnect as an abrupt drop rather than a controlled + /// disconnect; without the close handshake, the client only sees the TCP teardown after + /// Kestrel finishes draining. Best-effort: any exception (already-torn-down socket, + /// canceled token) is swallowed at Debug level since teardown races are routine. + /// + private async Task TryPoliteCloseAsync(WebSocket ws, string reason, string battleId) + { + // Use a fresh, short timeout — ctx.RequestAborted may already be canceled by the + // path that decided to bail out, which would skip the close immediately. + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + if (ws.State == WebSocketState.Open) + { + var bytes = Encoding.UTF8.GetBytes(((int)EngineIoPacketType.Close).ToString()); + await ws.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, cts.Token); + } + if (ws.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, reason, cts.Token); + } + } + catch (Exception ex) + { + _log.LogDebug(ex, + "polite close failed on BattleId={Bid} (reason={Reason}); socket likely already torn down.", + battleId, reason); + } + } } diff --git a/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs b/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs index a88b342..279700f 100644 --- a/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs @@ -368,21 +368,28 @@ public class BattleNodeFlowTests // NOTE: ConsumeHandshakeAsync is NOT called here. The EIO Open frame is sent inside // RealParticipant.RunAsync, which only runs once the session is constructed by the // SECOND arriver. The first arriver who times out never receives that frame — the - // handler parks them in AwaitSessionFinishedAsync, the waiting-room timer fires, the - // handler's HTTP method returns, and the TestServer-side WS shuts down. ReceiveAsync - // observes the shutdown either by returning a Close message or throwing. + // handler parks them in AwaitSessionFinishedAsync, the waiting-room timer fires, and + // the polite-close path emits an EIO "1" Close text frame followed by a clean + // WebSocket close handshake before the handler returns. + bool politeFrameObserved = false; bool closeObserved = false; var sw = System.Diagnostics.Stopwatch.StartNew(); + var buf = new byte[1024]; while (!closeObserved && sw.Elapsed < TimeSpan.FromSeconds(65)) { try { - var rr = await wsA.ReceiveAsync(new ArraySegment(new byte[1024]), ct); + var rr = await wsA.ReceiveAsync(new ArraySegment(buf), ct); if (rr.MessageType == System.Net.WebSockets.WebSocketMessageType.Close) { closeObserved = true; break; } + if (rr.MessageType == System.Net.WebSockets.WebSocketMessageType.Text) + { + var text = System.Text.Encoding.UTF8.GetString(buf, 0, rr.Count); + if (text == "1") politeFrameObserved = true; + } } catch { @@ -391,6 +398,8 @@ public class BattleNodeFlowTests break; } } + Assert.That(politeFrameObserved, Is.True, + "A's WS should receive an EIO '1' Close text frame before teardown (polite-close contract)."); Assert.That(closeObserved, Is.True, "A's WS should close (or ReceiveAsync should fail) after the waiting-room timeout."); wsA.Dispose();