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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user