refactor(battle-node): gate WS diagnostic logging behind config flag

The temporary [sio-in] / [sio-out] / [ws-rx-text] / [ws-rx-bin] /
[ws-recv-exit] / [ws-loop-exit] logs added during the hand-ack
investigation are useful enough to keep around (PvP testing, future WS
debugging) but too chatty to leave on by default. Promote them from
"strip before merge" to a permanent opt-in.

New BattleNodeOptions.DiagnosticLogging (bool, default false). Wired
through BattleNodeWebSocketHandler to RealParticipant via a new optional
ctor parameter (default false — existing test sites pick up the silent
default with no changes). Every Information/Warning log added during the
investigation is now if-gated; non-diagnostic logs (the decode-failure
warnings, the dispatch-drop debug) stay as-is.

Toggle via appsettings*.json:
  "BattleNode": { "DiagnosticLogging": true }

Or live via the singleton:
  factory.Services.GetRequiredService<BattleNodeOptions>().DiagnosticLogging = true

175 battle-node tests still passing — existing tests use the constructor
default and emit nothing, so no test changes were required.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-02 16:49:17 -04:00
parent 9fc1d055d8
commit 8f270a87f0
3 changed files with 59 additions and 40 deletions

View File

@@ -26,4 +26,18 @@ public sealed class BattleNodeOptions
/// is the only way to get PvP behavior.</para>
/// </summary>
public bool SoloDefaultsToScripted { get; set; } = false;
/// <summary>
/// When true, <see cref="Sessions.Participants.RealParticipant"/> emits per-frame
/// diagnostic logs at Information level: <c>[sio-in]</c> on every inbound msg/alive/hand
/// envelope (URI, pubSeq, ackId, dispatch decision, ack-sent flag, ack arg, inbound
/// watermark); <c>[sio-out]</c> on every outbound push (URI, pubSeq, playSeq, noStock);
/// <c>[ws-rx-text]</c> / <c>[ws-rx-bin]</c> on every WS frame received at the transport
/// layer; <c>[ws-recv-exit]</c> / <c>[ws-loop-exit]</c> on read-loop termination
/// (with WebSocket state + exception type when applicable). Default false — keeps
/// production logs clean. Flip on per session for live WS debugging, PvP investigation,
/// or to reproduce the kind of softlock chased in
/// <c>docs/audits/battle-node-sio-events-2026-06-02.md</c>.
/// </summary>
public bool DiagnosticLogging { get; set; } = false;
}

View File

@@ -124,7 +124,7 @@ public sealed class BattleNodeWebSocketHandler
{
_store.RemovePending(battleId);
var realParticipant = new RealParticipant(ws, viewerId, pending.P1.Context,
_loggerFactory.CreateLogger<RealParticipant>());
_loggerFactory.CreateLogger<RealParticipant>(), _options.DiagnosticLogging);
var scriptedBot = new ScriptedBotParticipant();
var session = new BattleSession(battleId, pending.Type, realParticipant, scriptedBot,
_loggerFactory.CreateLogger<BattleSession>());
@@ -137,7 +137,7 @@ public sealed class BattleNodeWebSocketHandler
// Pick this connection's MatchContext (P1's if isP1, P2's if isP2).
var selfCtx = isP1 ? pending.P1.Context : pending.P2!.Context;
var self = new RealParticipant(ws, viewerId, selfCtx,
_loggerFactory.CreateLogger<RealParticipant>());
_loggerFactory.CreateLogger<RealParticipant>(), _options.DiagnosticLogging);
var firstArriver = _waitingRoom.Pair(battleId, self);
@@ -202,7 +202,7 @@ public sealed class BattleNodeWebSocketHandler
// earlier isP1/isP2 check has already rejected viewer mismatches.
_store.RemovePending(battleId);
var botReal = new RealParticipant(ws, viewerId, pending.P1.Context,
_loggerFactory.CreateLogger<RealParticipant>());
_loggerFactory.CreateLogger<RealParticipant>(), _options.DiagnosticLogging);
var noopBot = new NoOpBotParticipant();
var botSession = new BattleSession(battleId, BattleType.Bot, botReal, noopBot,
_loggerFactory.CreateLogger<BattleSession>());

View File

@@ -33,6 +33,7 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
{
private readonly WebSocket _ws;
private readonly ILogger<RealParticipant> _log;
private readonly bool _diagnosticLogging;
private CancellationToken _sessionCt;
public long ViewerId { get; }
@@ -85,10 +86,11 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
}
public RealParticipant(WebSocket ws, long viewerId, MatchContext context,
ILogger<RealParticipant> log)
ILogger<RealParticipant> log, bool diagnosticLogging = false)
{
_ws = ws;
_log = log;
_diagnosticLogging = diagnosticLogging;
ViewerId = viewerId;
Context = context;
}
@@ -115,11 +117,13 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
var text = Encoding.UTF8.GetString(msg.Value.Bytes);
if (text.Length == 0) continue;
var eio = EngineIoFrame.Parse(text);
// Diagnostic: log every EIO frame type so we can see Ping/Close packets too.
_log.LogInformation(
"[ws-rx-text] viewer={Vid} eioType={Eio} len={Len} preview={Preview}",
ViewerId, eio.Type, text.Length,
text.Length > 60 ? text.Substring(0, 60) + "..." : text);
if (_diagnosticLogging)
{
_log.LogInformation(
"[ws-rx-text] viewer={Vid} eioType={Eio} len={Len} preview={Preview}",
ViewerId, eio.Type, text.Length,
text.Length > 60 ? text.Substring(0, 60) + "..." : text);
}
if (eio.Type == EngineIoPacketType.Ping)
{
await SendTextAsync("3", cancellation);
@@ -144,9 +148,12 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
bin = bin.AsSpan(1).ToArray();
}
pendingAttachments.Add(bin);
_log.LogInformation(
"[ws-rx-bin] viewer={Vid} binLen={Len} pendingFrame={Pending} attachCount={AttachCount}",
ViewerId, bin.Length, pendingFrame?.EventName ?? "(null)", pendingAttachments.Count);
if (_diagnosticLogging)
{
_log.LogInformation(
"[ws-rx-bin] viewer={Vid} binLen={Len} pendingFrame={Pending} attachCount={AttachCount}",
ViewerId, bin.Length, pendingFrame?.EventName ?? "(null)", pendingAttachments.Count);
}
if (pendingFrame is not null && pendingAttachments.Count == pendingFrame.AttachmentCount)
{
var assembled = pendingFrame.WithAttachments(pendingAttachments.ToArray());
@@ -163,20 +170,24 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
}
finally
{
_log.LogWarning(
"[ws-loop-exit] viewer={Vid} reason={Reason} wsState={State} cancelled={Cancelled}",
ViewerId, exitReason, _ws.State, cancellation.IsCancellationRequested);
if (_diagnosticLogging)
{
_log.LogWarning(
"[ws-loop-exit] viewer={Vid} reason={Reason} wsState={State} cancelled={Cancelled}",
ViewerId, exitReason, _ws.State, cancellation.IsCancellationRequested);
}
}
}
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
{
var stamped = noStock ? Outbound.WrapNoStock(envelope) : Outbound.AssignAndArchive(envelope);
// Temporary diagnostic log to pair with the [sio-in] log — gives full inbound/outbound
// sequence numbering for the scripted-attack TurnEnd-never-fires investigation.
_log.LogInformation(
"[sio-out] viewer={Vid} uri={Uri} pubSeq={Pseq} playSeq={Plseq} noStock={NoStock}",
ViewerId, stamped.Uri, stamped.PubSeq, stamped.PlaySeq, noStock);
if (_diagnosticLogging)
{
_log.LogInformation(
"[sio-out] viewer={Vid} uri={Uri} pubSeq={Pseq} playSeq={Plseq} noStock={NoStock}",
ViewerId, stamped.Uri, stamped.PubSeq, stamped.PlaySeq, noStock);
}
await EncodeAndSendAsync(stamped, WireConstants.SynchronizeEvent, ct);
}
@@ -241,21 +252,12 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
ackArg = env.PubSeq.Value;
}
}
// Temporary diagnostic log to chase a scripted-bot bug where the client
// sends TurnEndActions after a PlayActions(type=10) attack but never sends the
// follow-up TurnEnd. We need to confirm whether the server actually sent the
// SIO ack for the offending TurnEndActions (and what arg). Log shape:
// uri / pubSeq / ackId / dispatch / ackSent / ackArg / watermark
_log.LogInformation(
"[sio-in] viewer={Vid} uri={Uri} pubSeq={Pseq} ackId={AckId} dispatch={Dispatch} ackSent={AckSent} ackArg={AckArg} highWaterMark={Hwm}",
ViewerId,
env.Uri,
env.PubSeq,
frame.AckId,
shouldDispatch,
ackSent,
ackArg,
Inbound.HighWaterMark);
if (_diagnosticLogging)
{
_log.LogInformation(
"[sio-in] viewer={Vid} uri={Uri} pubSeq={Pseq} ackId={AckId} dispatch={Dispatch} ackSent={AckSent} ackArg={AckArg} highWaterMark={Hwm}",
ViewerId, env.Uri, env.PubSeq, frame.AckId, shouldDispatch, ackSent, ackArg, Inbound.HighWaterMark);
}
if (!shouldDispatch) return;
@@ -409,19 +411,22 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
try { result = await _ws.ReceiveAsync(buffer, ct); }
catch (OperationCanceledException)
{
_log.LogWarning("[ws-recv-exit] viewer={Vid} reason=OperationCanceled wsState={State}", ViewerId, _ws.State);
if (_diagnosticLogging)
_log.LogWarning("[ws-recv-exit] viewer={Vid} reason=OperationCanceled wsState={State}", ViewerId, _ws.State);
return null;
}
catch (WebSocketException wsex)
{
_log.LogWarning(wsex, "[ws-recv-exit] viewer={Vid} reason=WebSocketException wsState={State} errCode={ErrCode}",
ViewerId, _ws.State, wsex.WebSocketErrorCode);
if (_diagnosticLogging)
_log.LogWarning(wsex, "[ws-recv-exit] viewer={Vid} reason=WebSocketException wsState={State} errCode={ErrCode}",
ViewerId, _ws.State, wsex.WebSocketErrorCode);
return null;
}
if (result.MessageType == WebSocketMessageType.Close)
{
_log.LogWarning("[ws-recv-exit] viewer={Vid} reason=ClientCloseFrame wsState={State} closeStatus={Status} desc={Desc}",
ViewerId, _ws.State, result.CloseStatus, result.CloseStatusDescription);
if (_diagnosticLogging)
_log.LogWarning("[ws-recv-exit] viewer={Vid} reason=ClientCloseFrame wsState={State} closeStatus={Status} desc={Desc}",
ViewerId, _ws.State, result.CloseStatus, result.CloseStatusDescription);
return null;
}
ms.Write(buffer, 0, result.Count);