fix(battle-node): dispose participants, unsubscribe events, filter catch

#5: BattleSession.RunAsync now unsubscribes FrameEmitted handlers
(-= OnFrameFromA/B) before termination and calls DisposeAsync on
both participants + the dispatch gate SemaphoreSlim afterward. This
unpins the session state from live delegates and releases the WS.

#6: Bare catch {} blocks replaced with filtered exception handlers
that silently swallow OperationCanceledException and WebSocketException
(expected at battle end) but log anything else at Warning. NREs and
other real bugs in handler threads are now visible instead of silently
eaten.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-04 22:00:28 -04:00
parent 99129c786c
commit c6fb411861

View File

@@ -1,3 +1,4 @@
using System.Net.WebSockets;
using Microsoft.Extensions.Logging;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Sessions.Dispatch;
@@ -125,20 +126,34 @@ public sealed class BattleSession
cts.Cancel(); // unblock the survivor's RunAsync read loop
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
catch { /* swallow cancellation / WS exceptions */ }
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException) { }
catch (AggregateException ex) when (ex.Flatten().InnerExceptions.All(
e => e is OperationCanceledException or WebSocketException)) { }
catch (Exception ex)
{
_log.LogWarning(ex, "BattleSession {Bid}: unexpected exception from WhenAll (PvP drain)", BattleId);
}
}
else
{
// Bot mode: the NoOp opponent's RunAsync returns immediately; wait for the real
// participant. The session keeps running for the real one.
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
catch { /* swallow */ }
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException) { }
catch (AggregateException ex) when (ex.Flatten().InnerExceptions.All(
e => e is OperationCanceledException or WebSocketException)) { }
catch (Exception ex)
{
_log.LogWarning(ex, "BattleSession {Bid}: unexpected exception from WhenAll (Bot drain)", BattleId);
}
}
// Audit Md11 — release per-participant outbound archives at battle-end
// (only RealParticipant has one; bots don't archive). Heavy state is
// dropped synchronously here so the participant's TerminateAsync doesn't
// need to keep the dict alive through its disposal handshake.
// Unsubscribe event handlers so the session + state aren't pinned by live delegates.
A.FrameEmitted -= OnFrameFromA;
B.FrameEmitted -= OnFrameFromB;
// Release per-participant outbound archives at battle-end
// (only RealParticipant has one; bots don't archive).
if (A is RealParticipant rpA) rpA.Outbound.Clear();
if (B is RealParticipant rpB) rpB.Outbound.Clear();
@@ -146,6 +161,10 @@ public sealed class BattleSession
A.TerminateAsync(BattleFinishReason.NormalFinish),
B.TerminateAsync(BattleFinishReason.NormalFinish))
.ConfigureAwait(false);
await A.DisposeAsync().ConfigureAwait(false);
await B.DisposeAsync().ConfigureAwait(false);
_dispatchGate.Dispose();
}
private Task OnFrameFromA(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(A, env, ct);