using System.Net; using System.Net.Sockets; using System.Reflection; 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); } [Fact] public async Task NetReceiveLoopAsync_PreservesArrivalAndAckOrder() { var transport = new OrderedDatagramTransport( BuildPacket(sequence: 41), BuildPacket(sequence: 42), BuildPacket(sequence: 43)); var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), transport); 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()); uint[] acked = transport.Sent .Select(static bytes => PacketCodec.TryDecode( bytes, inboundIsaac: null)) .Select(static decoded => { Assert.True(decoded.IsOk, decoded.Error.ToString()); return decoded.Packet!.Optional.AckSequence; }) .ToArray(); Assert.Equal([41u, 42u, 43u], acked); } private static byte[] BuildPacket(uint sequence) => PacketCodec.Encode( new PacketHeader { Sequence = sequence, Flags = PacketHeaderFlags.None, }, ReadOnlySpan.Empty, outboundIsaac: null); 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() { } } }