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() { }
}
}