Files
SVSimServer/SVSim.BattleNode/Sessions/BattleSession.cs
gamer147 cc32223d7d fix(battle-node): strip/prepend EIO3 type byte on binary WS frames
Engine.IO v3 frames over WebSocket prepend the packet-type byte (0x04
for Message) to BINARY frames, the binary analog of the leading digit
on text frames. The real client honors this and our session was
treating the entire binary frame as the Socket.IO attachment payload —
the msgpack decoder saw 0x04 as a positive fixint and failed
deserialization on every inbound msg event.

Symmetric fix: strip 0x04 from inbound binary frames in
BattleSession.RunAsync, prepend 0x04 to outbound binary frames in
EncodeAndSendAsync. RawSocketIoTestClient gets the same on both
directions so the integration test still exercises the same wire
shape as a real client.

Caught during v1 smoke walkthrough, after the WS upgrade started
succeeding (101 Switching Protocols).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:48:52 -04:00

306 lines
12 KiB
C#

using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using SVSim.BattleNode.Lifecycle;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Reliability;
using SVSim.BattleNode.Wire;
namespace SVSim.BattleNode.Sessions;
/// <summary>
/// One per connected client. Owns the WebSocket + reliability ledgers + lifecycle phase.
/// </summary>
public sealed class BattleSession
{
private readonly WebSocket _ws;
private readonly ILogger<BattleSession> _log;
public string BattleId { get; }
public long ViewerId { get; }
public BattleSessionPhase Phase { get; internal set; } = BattleSessionPhase.AwaitingInitNetwork;
public InboundTracker Inbound { get; } = new();
public OutboundSequencer Outbound { get; } = new();
public BattleSession(WebSocket ws, string battleId, long viewerId, ILogger<BattleSession> log)
{
_ws = ws;
_log = log;
BattleId = battleId;
ViewerId = viewerId;
}
/// <summary>
/// Send the EIO3 open handshake then run the read loop until the WS closes.
/// </summary>
public async Task RunAsync(CancellationToken cancellation)
{
await SendEioOpenAsync(cancellation);
var buffer = new byte[8192];
var pendingAttachments = new List<byte[]>();
SocketIoFrame? pendingFrame = null;
while (_ws.State == WebSocketState.Open && !cancellation.IsCancellationRequested)
{
var msg = await ReadCompleteMessageAsync(buffer, cancellation);
if (msg is null) break;
if (msg.Value.IsText)
{
var text = Encoding.UTF8.GetString(msg.Value.Bytes);
if (text.Length == 0) continue;
var eio = EngineIoFrame.Parse(text);
if (eio.Type == EngineIoPacketType.Ping)
{
await SendTextAsync("3", cancellation); // EIO3 pong
continue;
}
if (eio.Type != EngineIoPacketType.Message) continue;
// SIO inside the message payload.
var sio = SocketIoFrame.Parse(eio.Payload);
if (sio.AttachmentCount > 0)
{
pendingFrame = sio;
pendingAttachments.Clear();
continue;
}
DispatchSocketIo(sio);
}
else
{
// Binary frame — an attachment for a pending binary event.
// Engine.IO v3 prefixes binary WS frames with the packet-type byte
// (0x04 = Message), analogous to the leading digit on text frames.
// Strip it before treating the rest as the Socket.IO attachment payload.
var bin = msg.Value.Bytes;
if (bin.Length > 0 && bin[0] == (byte)EngineIoPacketType.Message)
{
bin = bin.AsSpan(1).ToArray();
}
pendingAttachments.Add(bin);
if (pendingFrame is not null && pendingAttachments.Count == pendingFrame.AttachmentCount)
{
var assembled = pendingFrame.WithAttachments(pendingAttachments.ToArray());
pendingFrame = null;
DispatchSocketIo(assembled);
}
}
}
}
private async void DispatchSocketIo(SocketIoFrame frame)
{
if (frame.Type is SocketIoPacketType.Event or SocketIoPacketType.BinaryEvent)
{
switch (frame.EventName)
{
case "msg" when frame.BinaryAttachments.Count == 1:
await HandleMsgEventAsync(frame);
return;
case "alive" when frame.BinaryAttachments.Count == 1:
await HandleAliveEventAsync(frame);
return;
}
}
// hand / unknown events: log and drop.
_log.LogDebug("BattleSession {Bid}: dropping SIO event={Event}", BattleId, frame.EventName);
}
private async Task HandleMsgEventAsync(SocketIoFrame frame)
{
try
{
MsgEnvelope env;
try { env = MsgPayloadCodec.Decode(frame.BinaryAttachments[0]); }
catch (Exception ex)
{
_log.LogWarning(ex, "BattleSession {Bid}: failed to decode msg envelope", BattleId);
return;
}
// Ack tracking + dedupe.
bool shouldDispatch = true;
if (env.PubSeq.HasValue)
{
shouldDispatch = Inbound.Observe(env.PubSeq.Value);
if (frame.AckId.HasValue)
{
await SendSioAckAsync(frame.AckId.Value, env.PubSeq.Value);
}
}
if (!shouldDispatch) return;
// Run the pure-logic decision and drive sends.
var responses = ComputeResponses(env);
foreach (var (responseEnv, noStock) in responses)
{
if (noStock)
await PushNoStockAsync(responseEnv);
else
await PushOrderedAsync(responseEnv);
}
}
catch (Exception ex)
{
_log.LogError(ex, "BattleSession {Bid}: unhandled exception in HandleMsgEventAsync", BattleId);
}
}
private async Task HandleAliveEventAsync(SocketIoFrame frame)
{
try
{
// Client emits Gungnir every 5s with an SIO ack callback expecting just liveness confirmation
// (payload ignored). We ack immediately, then push our own alive back with scs/ocs ONLINE
// placeholders — the only response the client uses to drive its scs/ocs state machine.
if (frame.AckId.HasValue)
{
await SendSioAckAsync(frame.AckId.Value, 0);
}
var aliveEnv = new MsgEnvelope(
Uri: NetworkBattleUri.Gungnir,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: "node-stub",
Bid: null,
Try: 0,
Cat: EmitCategory.General,
PubSeq: null,
PlaySeq: null,
Body: Gungnir.BuildAlivePushBody());
await PushNoStockAsync(aliveEnv, eventName: "alive");
}
catch (Exception ex)
{
_log.LogError(ex, "BattleSession {Bid}: unhandled exception in HandleAliveEventAsync", BattleId);
}
}
internal IReadOnlyList<(MsgEnvelope Envelope, bool NoStock)> ComputeResponses(MsgEnvelope env)
{
var result = new List<(MsgEnvelope Envelope, bool NoStock)>();
switch (env.Uri)
{
case NetworkBattleUri.InitNetwork when Phase == BattleSessionPhase.AwaitingInitNetwork:
result.Add((BuildAckedEnvelope(NetworkBattleUri.InitNetwork), NoStock: true));
result.Add((ScriptedLifecycle.BuildMatched(ViewerId, ScriptedLifecycle.FakeOpponentViewerId, BattleId), NoStock: false));
result.Add((ScriptedLifecycle.BuildBattleStart(ViewerId), NoStock: false));
Phase = BattleSessionPhase.AwaitingLoaded;
break;
case NetworkBattleUri.Loaded when Phase == BattleSessionPhase.AwaitingLoaded:
result.Add((ScriptedLifecycle.BuildDeal(), NoStock: false));
Phase = BattleSessionPhase.AwaitingSwap;
break;
case NetworkBattleUri.Swap when Phase == BattleSessionPhase.AwaitingSwap:
result.Add((ScriptedLifecycle.BuildSwapResponse(ExtractIdxList(env)), NoStock: false));
result.Add((ScriptedLifecycle.BuildReady(), NoStock: false));
Phase = BattleSessionPhase.AfterReady;
break;
case NetworkBattleUri.Retire:
case NetworkBattleUri.Kill:
// These always terminate, regardless of phase.
result.Add((BuildBattleFinishNoContest(), NoStock: true));
Phase = BattleSessionPhase.Terminal;
break;
default:
// Out-of-order or unknown URI: log and drop (no response).
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase}", BattleId, env.Uri, Phase);
break;
}
return result;
}
private MsgEnvelope BuildAckedEnvelope(NetworkBattleUri uri) => new(
uri,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: "node-stub",
Bid: null,
Try: 0,
Cat: EmitCategory.General,
PubSeq: null,
PlaySeq: null,
Body: new Dictionary<string, object?> { ["resultCode"] = 1 });
private MsgEnvelope BuildBattleFinishNoContest() => new(
NetworkBattleUri.BattleFinish,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: "node-stub",
Bid: null,
Try: 0,
Cat: EmitCategory.Battle,
PubSeq: null,
PlaySeq: null,
Body: new Dictionary<string, object?> { ["result"] = 1, ["resultCode"] = 1 });
private static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env)
{
if (env.Body.TryGetValue("idxList", out var raw) && raw is List<object?> lst)
return lst.OfType<long>().ToList();
return Array.Empty<long>();
}
private Task PushOrderedAsync(MsgEnvelope env, string eventName = "synchronize") =>
EncodeAndSendAsync(Outbound.AssignAndArchive(env), eventName);
private Task PushNoStockAsync(MsgEnvelope env, string eventName = "synchronize") =>
EncodeAndSendAsync(Outbound.WrapNoStock(env), eventName);
private async Task EncodeAndSendAsync(MsgEnvelope env, string eventName)
{
var key = NodeCrypto.GenerateKey(() => RandomNumberGenerator.GetInt32(0, 16));
var bytes = MsgPayloadCodec.Encode(env, key);
var sio = SocketIoFrame.BinaryEventWithAttachments(eventName, new[] { bytes });
var (text, bins) = sio.Encode();
var eioText = $"{(int)EngineIoPacketType.Message}{text}";
await SendTextAsync(eioText, CancellationToken.None);
foreach (var bin in bins)
{
// Engine.IO v3 binary frames are prefixed with the packet-type byte
// (0x04 = Message), the binary analog of the leading digit on text frames.
var prefixed = new byte[bin.Length + 1];
prefixed[0] = (byte)EngineIoPacketType.Message;
Buffer.BlockCopy(bin, 0, prefixed, 1, bin.Length);
await _ws.SendAsync(prefixed, WebSocketMessageType.Binary, endOfMessage: true, CancellationToken.None);
}
}
private async Task SendSioAckAsync(int ackId, long arg)
{
// checked() ensures we'd notice if pubSeq ever exceeded int.MaxValue (not realistic but defensive).
var ack = SocketIoFrame.AckResponse(ackId, checked((int)arg));
var (text, _) = ack.Encode();
var eioText = $"{(int)EngineIoPacketType.Message}{text}";
await SendTextAsync(eioText, CancellationToken.None);
}
private async Task SendEioOpenAsync(CancellationToken ct)
{
var sid = Guid.NewGuid().ToString("N").Substring(0, 16);
var handshake = new EngineIoHandshake(sid, Array.Empty<string>(), 25000, 60000).ToJson();
await SendTextAsync($"0{handshake}", ct);
}
private Task SendTextAsync(string text, CancellationToken ct)
{
var bytes = Encoding.UTF8.GetBytes(text);
return _ws.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
}
private async Task<(byte[] Bytes, bool IsText)?> ReadCompleteMessageAsync(byte[] buffer, CancellationToken ct)
{
using var ms = new MemoryStream();
WebSocketReceiveResult result;
do
{
try { result = await _ws.ReceiveAsync(buffer, ct); }
catch (OperationCanceledException) { return null; }
catch (WebSocketException) { return null; }
if (result.MessageType == WebSocketMessageType.Close) return null;
ms.Write(buffer, 0, result.Count);
} while (!result.EndOfMessage);
return (ms.ToArray(), result.MessageType == WebSocketMessageType.Text);
}
}