diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index e7ae2b0..a6b5dd6 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -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);