fix(battle-node): serialize per-session dispatch to stop cross-thread state race

In PvP a BattleSession subscribes to both participants' FrameEmitted, and each
RealParticipant raises it from its own WebSocket read loop -- two threads. The
dispatch path (ComputeFrames + the relay PushAsync calls) mutates shared,
non-thread-safe state: the BattleSessionState dictionaries (deck maps, post-swap
hands, idx->cardId reveal map). Concurrent frames from both players could corrupt
those dictionaries (InvalidOperationException / torn playSeq / wrong card identity).

Add a per-session SemaphoreSlim _dispatchGate around the whole HandleFrameAsync so
both read loops funnel through one critical section. ComputeFrames stays lock-free
(the direct-call test seam is single-threaded).

Analysis during the fix showed each OutboundSequencer is single-writer-per-instance
in steady state (A's loop only writes B's Outbound and vice-versa), so the live race
is the shared BattleSessionState, which the gate fully serializes.

TDD: BattleSessionDispatchConcurrencyTests drives both participants to AfterReady,
then fires TurnStart from both at once; the target PushAsync records peak in-flight
dispatches. Red (MaxConcurrent=2) before the gate, green (1) after.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-04 21:00:41 -04:00
parent 24180d5b4b
commit 77c99cc230
2 changed files with 136 additions and 0 deletions

View File

@@ -22,6 +22,13 @@ public sealed class BattleSession
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;
@@ -146,6 +153,7 @@ public sealed class BattleSession
private async Task HandleFrameAsync(IBattleParticipant from, MsgEnvelope env, CancellationToken ct)
{
await _dispatchGate.WaitAsync(ct).ConfigureAwait(false);
try
{
var routes = ComputeFrames(from, env);
@@ -158,6 +166,10 @@ public sealed class BattleSession
{
_log.LogError(ex, "BattleSession {Bid}: unhandled in HandleFrameAsync", BattleId);
}
finally
{
_dispatchGate.Release();
}
}
/// <summary>