Files
SVSimServer/SVSim.BattleNode/Sessions/BattleSession.cs
gamer147 c6fb411861 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>
2026-06-04 22:00:28 -04:00

211 lines
9.2 KiB
C#

using System.Net.WebSockets;
using Microsoft.Extensions.Logging;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Sessions.Dispatch;
using SVSim.BattleNode.Sessions.Dispatch.Handlers;
using SVSim.BattleNode.Sessions.Participants;
namespace SVSim.BattleNode.Sessions;
/// <summary>
/// v2 broker session. Holds two participants and brokers between them. Subscribes
/// to each participant's <see cref="IBattleParticipant.FrameEmitted"/>; on each frame,
/// runs <see cref="ComputeFrames"/> to determine the routing (target + frame + noStock
/// flag) and dispatches via <see cref="IBattleParticipant.PushAsync"/>.
/// </summary>
/// <remarks>
/// Wires both battle modes: Pvp (broadcast Matched/BattleStart per-perspective, forward
/// gameplay frames between the two real participants) and Bot (ack-only, NoOp opponent).
/// </remarks>
public sealed class BattleSession
{
private readonly ILogger<BattleSession> _log;
private readonly BattleSessionState _state = new();
/// <summary>Serializes dispatch. Both participants' read loops raise FrameEmitted on their own
/// threads, and a dispatch (<see cref="ComputeFrames"/> + the relay <c>PushAsync</c> calls) mutates
/// shared, non-thread-safe state — the <see cref="BattleSessionState"/> dictionaries and each
/// participant's <c>OutboundSequencer</c>. This gate funnels both threads through one critical
/// section so concurrent frames can't corrupt that state.</summary>
private readonly SemaphoreSlim _dispatchGate = new(1, 1);
/// <summary>The per-battle master seed (see <see cref="BattleSessionState.MasterSeed"/>).
/// Exposed for logging + future replay persistence.</summary>
public int MasterSeed => _state.MasterSeed;
public string BattleId { get; }
public BattleType Type { get; }
public IBattleParticipant A { get; }
public IBattleParticipant B { get; }
public BattleSessionPhase Phase => _state.SessionPhase;
// Per-URI dispatch table. All 14 inbound URIs are registered (Tasks 5-14); unknown
// URIs are dropped with a LogDebug in ComputeFrames.
private static readonly IReadOnlyDictionary<NetworkBattleUri, IFrameHandler> Handlers = BuildHandlers();
private static IReadOnlyDictionary<NetworkBattleUri, IFrameHandler> BuildHandlers()
{
var retireKill = new RetireKillHandler();
var forwardWhenReady = new ForwardWhenBothReadyHandler();
return new Dictionary<NetworkBattleUri, IFrameHandler>
{
[NetworkBattleUri.InitNetwork] = new InitNetworkHandler(),
[NetworkBattleUri.InitBattle] = new InitBattleHandler(),
[NetworkBattleUri.Loaded] = new LoadedHandler(),
[NetworkBattleUri.Swap] = new SwapHandler(),
[NetworkBattleUri.TurnEnd] = new TurnEndHandler(),
[NetworkBattleUri.TurnEndFinal] = new TurnEndFinalHandler(),
[NetworkBattleUri.Retire] = retireKill,
[NetworkBattleUri.Kill] = retireKill,
[NetworkBattleUri.TurnStart] = new TurnStartHandler(),
[NetworkBattleUri.Judge] = new JudgeHandler(),
[NetworkBattleUri.PlayActions] = new PlayActionsHandler(),
[NetworkBattleUri.Echo] = new EchoHandler(),
[NetworkBattleUri.TurnEndActions] = new TurnEndActionsHandler(),
[NetworkBattleUri.JudgeResult] = forwardWhenReady,
};
}
private FrameDispatchContext BuildContext(IBattleParticipant from, MsgEnvelope env) =>
new()
{
A = A, B = B, From = from, Other = ReferenceEquals(from, A) ? B : A,
Env = env, Type = Type, BattleId = BattleId, State = _state,
};
public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b,
ILogger<BattleSession> log)
{
BattleId = battleId;
Type = type;
A = a;
B = b;
_log = log;
_log.LogInformation("BattleSession {Bid}: master seed {Seed}", BattleId, _state.MasterSeed);
// Subscribe to both participants' emissions.
A.FrameEmitted += OnFrameFromA;
B.FrameEmitted += OnFrameFromB;
}
public async Task RunAsync(CancellationToken cancellation)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
var aTask = A.RunAsync(cts.Token);
var bTask = B.RunAsync(cts.Token);
if (Type == BattleType.Pvp)
{
// WhenAny: first WS drop / first graceful close triggers cascade. Pvp has two
// RealParticipants; we synthesize a BattleFinish for the survivor if either side
// terminates first.
var first = await Task.WhenAny(aTask, bTask).ConfigureAwait(false);
var survivor = first == aTask ? B : A;
if (Phase != BattleSessionPhase.Terminal)
{
// Involuntary drop (no graceful Retire): synthesize BattleFinish(DisconnectWin)
// to survivor. DisconnectWin=201 → client renders "opponent disconnected" →
// WIN UI; the legacy Win=1 used here previously rendered "no contest".
try
{
await survivor.PushAsync(
BattleFrames.BuildBattleFinish(BattleResult.DisconnectWin), noStock: true, cancellation)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_log.LogWarning(ex,
"BattleSession {Bid}: failed to push BattleFinish to survivor (their WS may also be closed)",
BattleId);
}
_state.SessionPhase = BattleSessionPhase.Terminal;
}
cts.Cancel(); // unblock the survivor's RunAsync read loop
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
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 (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);
}
}
// 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();
await Task.WhenAll(
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);
private Task OnFrameFromB(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(B, env, ct);
private async Task HandleFrameAsync(IBattleParticipant from, MsgEnvelope env, CancellationToken ct)
{
await _dispatchGate.WaitAsync(ct).ConfigureAwait(false);
try
{
var routes = ComputeFrames(from, env);
foreach (var (target, frame, noStock) in routes)
{
await target.PushAsync(frame, noStock, ct);
}
}
catch (Exception ex)
{
_log.LogError(ex, "BattleSession {Bid}: unhandled in HandleFrameAsync", BattleId);
}
finally
{
_dispatchGate.Release();
}
}
/// <summary>
/// Pure-logic dispatch: given an inbound frame from one participant, return the list
/// of (target, frame, noStock) tuples the session should dispatch. Transitions
/// <see cref="Phase"/>. Extracted so unit tests can drive the dispatch without
/// standing up real participants.
/// </summary>
internal IReadOnlyList<DispatchRoute> ComputeFrames(IBattleParticipant from, MsgEnvelope env)
{
if (Handlers.TryGetValue(env.Uri, out var handler))
return handler.Handle(BuildContext(from, env));
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase} from vid={Vid}",
BattleId, env.Uri, Phase, from.ViewerId);
return Array.Empty<DispatchRoute>();
}
}