From c6fb411861f1a41586494ade827bfa2c0876925d Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 4 Jun 2026 22:00:28 -0400 Subject: [PATCH] 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 --- SVSim.BattleNode/Sessions/BattleSession.cs | 31 +++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) 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);