perf(net): own one pooled async receive

Replace timeout-polled in-world UDP receives with one cancellable caller-buffered socket operation. Transfer only right-sized pooled datagrams through the FIFO, return every ownership edge deterministically, and send caller spans without a transport copy while preserving handshake pacing and ACK order.
This commit is contained in:
Erik 2026-07-25 05:50:18 +02:00
parent a2a1e5916d
commit 7211bb1bf7
10 changed files with 547 additions and 87 deletions

View file

@ -1,6 +1,7 @@
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Tests;
@ -19,7 +20,7 @@ namespace AcDream.Core.Net.Tests;
public sealed class WorldSessionNetReceiveLoopResilienceTests
{
[Fact]
public void NetReceiveLoop_NonTimeoutSocketException_LogsAndKeepsReceiving()
public async Task NetReceiveLoop_NonTimeoutSocketException_LogsAndKeepsReceiving()
{
var transport = new ScriptedTransport();
var session = new WorldSession(
@ -27,7 +28,8 @@ public sealed class WorldSessionNetReceiveLoopResilienceTests
transport);
MethodInfo loopMethod = typeof(WorldSession).GetMethod(
"NetReceiveLoop", BindingFlags.NonPublic | BindingFlags.Instance)!;
"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
@ -36,12 +38,7 @@ public sealed class WorldSessionNetReceiveLoopResilienceTests
// 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 thread = new Thread(() => loopMethod.Invoke(session, null))
{
IsBackground = true,
Name = "test.net-receive-loop",
};
thread.Start();
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
@ -50,7 +47,8 @@ public sealed class WorldSessionNetReceiveLoopResilienceTests
// would exit after call 1 and this thread would never terminate on
// its own via the scripted ObjectDisposedException (call 3 would
// never be reached).
bool exited = thread.Join(TimeSpan.FromSeconds(5));
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.");
@ -66,6 +64,48 @@ public sealed class WorldSessionNetReceiveLoopResilienceTests
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<byte>.Empty,
outboundIsaac: null);
private sealed class ScriptedTransport : IWorldSessionTransport
{
private int _calls;
@ -76,10 +116,20 @@ public sealed class WorldSessionNetReceiveLoopResilienceTests
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from)
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);
from = null;
switch (n)
{
case 1:
@ -87,8 +137,14 @@ public sealed class WorldSessionNetReceiveLoopResilienceTests
// WSAECONNRESET off a stale peer's ICMP port-unreachable.
throw new SocketException((int)SocketError.ConnectionReset);
case 2:
from = new IPEndPoint(IPAddress.Loopback, 9000);
return new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
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,
@ -99,4 +155,48 @@ public sealed class WorldSessionNetReceiveLoopResilienceTests
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() { }
}
}