Campaign N slice N3. Retail never acks per packet: SharedNet::EnqueuePak @ 0x00543B10 is the binary's only AckSequence (0x4000) construction site, gated at >= 2.0 s on ReceiverData::timeStamp_ (@ +0x10), armed at connection birth by ReceiverData::Init @ 0x00548EF0, and arbitrated NAK-xor-ack per sweep by ClientNet::ProcessConnection @ 0x00545450 (m_SeqIDsWeNAKed non-empty -> EnqueueNaks, else EnqueuePak; SharedNet::EnqueueNaks @ 0x00543BD0 shares the SAME timestamp - campaign landmine #7). - New Transport/AckNakScheduler: owns the one shared timestamp; a non-empty NAK set suppresses the ack (N4 emits RequestRetransmit in that branch; in N3 it emits nothing - a documented transitional state, safe for exactly one slice on loopback), else ONE cleartext exact-flags AckSequence carrying the tracker's HighestIdReceived, header sequence borrowed from HighestIdSent without incrementing, 4-byte LE body. Flags are an EQUALITY, never an OR (landmine #5 - ACE's dedup exemption NetworkSession.cs:342-343 and watermark-skip :474-476 both require the exact value). - ReliableTransport.Sweep pump order per FlowQueue::Empty @ 0x00548A20: interval clock, NAK/ack arbitration, pending resends, prune. The sweep already runs in Tick and both handshake pump loops (landmine #8), so cumulative acks flow during the character-list/enter-world floods at ACE's own ~2 s cadence. - WorldSession: the Phase 4.9 per-packet reflex ack in ProcessDatagram and SendAck are DELETED; the [net-tick] acks/s probe now reads Stats.AcksSent; new internal TransportClockSource seam drives the 2.0 s gate on virtual time in the conformance suite. - N1 Fable-review advisory retired (Time-stamp fold-in): fresh reliable sends now stamp Header.Time = the current interval id, matching retail FlowQueue::TransmitNewPackets @ 0x00547A60 (header build at 0x00547A84); resends already re-stamped. ACE never reads inbound Header.Time, so the wire stays compatible. Tests: 723 Core.Net (7 new) - gate cadence + watermark-at-emission, flags-equality pin + model acceptance at the reused sequence without a watermark advance, NAK suppression and resume after the gap clears, a 50-packet CreateObject flood collapsing to ONE ack, the quiet-session keepalive property across a 120 s virtual horizon (the reflex ack's keepalive role, replaced and proven against ACE's 60 s TimeoutDeadline), the Time fold-in, and a full FakeAceTransport lifecycle with zero CRC/state/duplicate drops. Full solution Release: 9,744 passed / 5 skipped / 0 failed. Connected world-lifecycle gate PASS (capped + uncapped-reconnect, graceful exits, 0 failures); canonical nine-stop route PASS (0 failures). Campaign section 9 N3 row updated (complete; SHA recorded at N4 kickoff). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
228 lines
8.9 KiB
C#
228 lines
8.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 2026-07-24 audit fix: <c>WorldSession.NetReceiveLoop</c> used to wrap its
|
|
/// entire while loop in a single try/catch, so a non-timeout
|
|
/// <see cref="SocketException"/> (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 <c>NetReceiveLoop</c>
|
|
/// method — via the internal <see cref="IWorldSessionTransport"/> 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <c>SharedNet::EnqueuePak @ 0x00543B10</c>), so the ordering witness
|
|
/// is now the dispatched message stream itself — and the session must
|
|
/// send NO per-packet acks at all.
|
|
/// </summary>
|
|
[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<string>();
|
|
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<byte> datagram) { }
|
|
|
|
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
|
|
|
|
public int Receive(
|
|
Span<byte> destination,
|
|
TimeSpan timeout,
|
|
out IPEndPoint? from)
|
|
{
|
|
from = null;
|
|
return -1;
|
|
}
|
|
|
|
public ValueTask<NetReceiveResult> ReceiveAsync(
|
|
Memory<byte> 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<byte[]> Sent { get; } = [];
|
|
|
|
public void Send(ReadOnlySpan<byte> datagram) =>
|
|
Sent.Add(datagram.ToArray());
|
|
|
|
public void Send(
|
|
IPEndPoint remote,
|
|
ReadOnlySpan<byte> datagram) =>
|
|
Sent.Add(datagram.ToArray());
|
|
|
|
public int Receive(
|
|
Span<byte> destination,
|
|
TimeSpan timeout,
|
|
out IPEndPoint? from)
|
|
{
|
|
from = null;
|
|
return -1;
|
|
}
|
|
|
|
public ValueTask<NetReceiveResult> ReceiveAsync(
|
|
Memory<byte> 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() { }
|
|
}
|
|
}
|