From 5c4e427fabea21343d43f7f4e0a4037a0e61df2f Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 2 Jun 2026 13:10:15 -0400 Subject: [PATCH] feat(battle-node): clear RealParticipant outbound archive on session terminate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes audit Md11. BattleSession.RunAsync now clears each RealParticipant.Outbound archive immediately before the TerminateAsync cascade, releasing the heavy dict the moment the battle ends instead of waiting for the participant to be GC'd. Bots (NoOp / Scripted) don't expose an OutboundSequencer, so the 'p is RealParticipant rp' conditional cast is the natural filter. Tests: 1 new BattleSessionTerminateCascadeTests — pre-load the archive, drive RunAsync to completion via TestWebSocket.CompleteIncoming, assert the archive is empty. Suite: 939 → 948. Co-Authored-By: Claude Opus 4.7 --- SVSim.BattleNode/Sessions/BattleSession.cs | 7 +++ .../BattleSessionTerminateCascadeTests.cs | 59 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 SVSim.UnitTests/BattleNode/Sessions/BattleSessionTerminateCascadeTests.cs diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index 0067345..ce9e2d4 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -86,6 +86,13 @@ public sealed class BattleSession catch { /* swallow */ } } + // 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. + 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)) diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionTerminateCascadeTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionTerminateCascadeTests.cs new file mode 100644 index 0000000..46f493c --- /dev/null +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionTerminateCascadeTests.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using SVSim.BattleNode.Bridge; +using SVSim.BattleNode.Protocol; +using SVSim.BattleNode.Protocol.Bodies; +using SVSim.BattleNode.Sessions; +using SVSim.BattleNode.Sessions.Participants; +using SVSim.UnitTests.BattleNode.Infrastructure; + +namespace SVSim.UnitTests.BattleNode.Sessions; + +/// +/// Audit Md11 — confirms drops the per-RealParticipant +/// archive when the session +/// terminates. The Scripted bot has no outbound archive of its own, so the test uses a +/// Scripted session (one Real, one ScriptedBot) and asserts only the Real side's archive +/// is cleared. +/// +[TestFixture] +public class BattleSessionTerminateCascadeTests +{ + [Test, Timeout(10_000)] + public async Task RunAsync_clears_real_participant_archive_on_terminate() + { + var ws = new TestWebSocket(); + var real = new RealParticipant( + ws, viewerId: 1, MakeFakeContext(), NullLogger.Instance); + var bot = new ScriptedBotParticipant(); + + // Pre-load the archive so we can prove it was cleared (not just empty). + real.Outbound.AssignAndArchive(MakeEnvelope(NetworkBattleUri.Matched)); + real.Outbound.AssignAndArchive(MakeEnvelope(NetworkBattleUri.BattleStart)); + Assume.That(real.Outbound.Archive.Count, Is.EqualTo(2), "Precondition: archive populated."); + + var session = new BattleSession( + battleId: "test-bid", type: BattleType.Scripted, + a: real, b: bot, log: NullLogger.Instance); + + // Drive RunAsync to completion: closing the incoming side causes + // RealParticipant's read loop to return Close → RunAsync exits → terminate + // cascade fires. + var runTask = session.RunAsync(CancellationToken.None); + ws.CompleteIncoming(); + await runTask; + + Assert.That(real.Outbound.Archive, Is.Empty, + "RealParticipant's outbound archive must be cleared by the terminate cascade."); + } + + private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri) => + new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle, + PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary())); + + private static MatchContext MakeFakeContext() => new( + SelfDeckCardIds: Array.Empty(), + ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", + CountryCode: "JP", UserName: "Test", SleeveId: "0", + EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 11); +}