feat(battle-node): polite Socket.IO close on waiting-room timeout

The PvP waiting-room timeout path in BattleNodeWebSocketHandler used to
return immediately after RemovePending, leaving the parked first arriver
to learn about the disconnect via TCP teardown after Kestrel finished
draining the request. BestHTTP / socket.io-client log that as an abrupt
drop rather than a controlled disconnect.

New TryPoliteCloseAsync helper emits an EIO "1" (Close) text frame, then
runs the WebSocket close handshake with NormalClosure. Wrapped in
try/catch + Debug log — teardown races between the server-side close and
client disconnect are routine and not actionable. Uses a fresh 5s CTS so
ctx.RequestAborted being canceled doesn't skip the close.

Wired into both bail-out paths post-AcceptWebSocketAsync that previously
just returned:
- PvP waiting-room timeout / Park-Park race (the main case, per PLAN.md
  L104 (c))
- Unknown BattleType default case (same shape, log message already said
  "closing WS" but didn't actually close — opportunistic fix)

PvpWaitingRoomTimeout integration test tightened: now asserts the polite
"1" text frame arrives before the close handshake, not just that the WS
eventually closes by any means.

172 battle-node tests passing (was 172 before the assertion tightening;
the existing timeout test stayed in.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-02 14:05:25 -04:00
parent a6b9a942ab
commit 9f11896f7b
2 changed files with 50 additions and 4 deletions

View File

@@ -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<byte>(new byte[1024]), ct);
var rr = await wsA.ReceiveAsync(new ArraySegment<byte>(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();