using System.Net; using System.Net.Sockets; using System.Reflection; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; namespace AcDream.Core.Net.Tests; /// /// 2026-07-24 audit fix: WorldSession.NetReceiveLoop used to wrap its /// entire while loop in a single try/catch, so a non-timeout /// (classically Windows delivering a delayed /// WSAECONNRESET off an earlier Send's ICMP port-unreachable) exited the /// loop for good and silently killed all inbound processing for the rest of /// the session. These tests drive the actual private NetReceiveLoop /// method — via the internal seam and /// reflection, since the method has no public entry point — with a scripted /// transport that throws a non-timeout SocketException on the first call. /// No real sockets are involved, so the tests are deterministic. /// public sealed class WorldSessionNetReceiveLoopResilienceTests { [Fact] public async Task NetReceiveLoop_NonTimeoutSocketException_LogsAndKeepsReceiving() { var transport = new ScriptedTransport(); var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), transport); MethodInfo loopMethod = typeof(WorldSession).GetMethod( "NetReceiveLoopAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; // Deliberately does NOT redirect Console.Error: xUnit runs test // classes in this assembly in parallel by default, and Console.Error // is process-global mutable state, so swapping it out here could // race with another class's concurrently running test and either // swallow its output or restore the wrong writer. The behavioral // assertions below (call count + queue drain) fully cover the fix // without needing to observe the log line's exact text. var task = (Task)loopMethod.Invoke(session, null)!; // The scripted transport throws SocketException on call 1, returns // one datagram on call 2, then throws ObjectDisposedException on // call 3 to end the loop the same way a real shutdown-time // disposal would. If the pre-fix bug were still present, the loop // would exit after call 1 and this thread would never terminate on // its own via the scripted ObjectDisposedException (call 3 would // never be reached). await task.WaitAsync(TimeSpan.FromSeconds(5)); bool exited = task.IsCompletedSuccessfully; Assert.True(exited, "NetReceiveLoop did not exit after the scripted ObjectDisposedException — " + "the non-timeout SocketException likely killed the loop before it reached call 3."); // Proves the loop survived the first (non-timeout) SocketException // and went on to make a second AND third Receive() call, instead of // exiting after call 1 the way the pre-fix code did. Assert.Equal(3, transport.CallCount); // The datagram handed back on call 2 must have made it into the // inbound queue — i.e. the loop's normal write path still runs // after recovering from the error. int processed = session.Tick(); Assert.Equal(1, processed); } /// /// The loop + channel preserve arrival order end-to-end. Pre-N3 this /// was asserted through the per-packet reflex acks; the AckNakScheduler /// replaced those with one cumulative ack per 2.0 s (retail /// SharedNet::EnqueuePak @ 0x00543B10), so the ordering witness /// is now the dispatched message stream itself — and the session must /// send NO per-packet acks at all. /// [Fact] public async Task NetReceiveLoopAsync_PreservesArrivalOrder_NoReflexAcks() { var transport = new OrderedDatagramTransport( BuildPacket(sequence: 41, fragmentSequence: 1, "first"), BuildPacket(sequence: 42, fragmentSequence: 2, "second"), BuildPacket(sequence: 43, fragmentSequence: 3, "third")); var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), transport); var messages = new List(); session.ServerMessageReceived += m => messages.Add(m.Message); MethodInfo loopMethod = typeof(WorldSession).GetMethod( "NetReceiveLoopAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; var task = (Task)loopMethod.Invoke(session, null)!; await task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal(3, session.Tick()); Assert.Equal(["first", "second", "third"], messages); // Retail never acks per packet: nothing goes out in response to // inbound datagrams (the cumulative ack lives on the negotiated // transport's 2.0 s sweep, and no transport was negotiated here). Assert.Empty(transport.Sent); } private static byte[] BuildPacket( uint sequence, uint fragmentSequence, string text) { byte[] message = BuildServerMessage(text); byte[] body = new byte[MessageFragmentHeader.Size + message.Length]; int written = GameMessageFragment.WriteSingleFragment( body, fragmentSequence, GameMessageGroup.UIQueue, message); return PacketCodec.Encode( new PacketHeader { Sequence = sequence, Flags = PacketHeaderFlags.BlobFragments, }, body.AsSpan(0, written), outboundIsaac: null); } private static byte[] BuildServerMessage(string text) { var writer = new PacketWriter(64 + text.Length); writer.WriteUInt32(ServerMessage.Opcode); // 0xF7E0 writer.WriteString16L(text); writer.WriteUInt32(1); // ChatMessageType return writer.ToArray(); } private sealed class ScriptedTransport : IWorldSessionTransport { private int _calls; public int CallCount => _calls; public void Send(ReadOnlySpan datagram) { } public void Send(IPEndPoint remote, ReadOnlySpan datagram) { } public int Receive( Span destination, TimeSpan timeout, out IPEndPoint? from) { from = null; return -1; } public ValueTask ReceiveAsync( Memory destination, CancellationToken cancellationToken) { int n = Interlocked.Increment(ref _calls); switch (n) { case 1: // Simulates a non-timeout SocketException, e.g. Windows' // WSAECONNRESET off a stale peer's ICMP port-unreachable. throw new SocketException((int)SocketError.ConnectionReset); case 2: new byte[] { 0xAA, 0xBB, 0xCC, 0xDD } .CopyTo(destination); return ValueTask.FromResult( new NetReceiveResult( 4, new IPEndPoint( IPAddress.Loopback, 9000))); default: // Simulates NetClient having been disposed out from // under the loop during shutdown — the pre-existing, // still-preserved silent-exit path. throw new ObjectDisposedException(nameof(ScriptedTransport)); } } public void Dispose() { } } private sealed class OrderedDatagramTransport( params byte[][] datagrams) : IWorldSessionTransport { private int _next; public List Sent { get; } = []; public void Send(ReadOnlySpan datagram) => Sent.Add(datagram.ToArray()); public void Send( IPEndPoint remote, ReadOnlySpan datagram) => Sent.Add(datagram.ToArray()); public int Receive( Span destination, TimeSpan timeout, out IPEndPoint? from) { from = null; return -1; } public ValueTask ReceiveAsync( Memory destination, CancellationToken cancellationToken) { int index = Interlocked.Increment(ref _next) - 1; if (index >= datagrams.Length) throw new ObjectDisposedException( nameof(OrderedDatagramTransport)); byte[] source = datagrams[index]; source.CopyTo(destination); return ValueTask.FromResult( new NetReceiveResult( source.Length, new IPEndPoint(IPAddress.Loopback, 9000))); } public void Dispose() { } } }