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:
parent
a2a1e5916d
commit
7211bb1bf7
10 changed files with 547 additions and 87 deletions
|
|
@ -15,12 +15,20 @@ public sealed class LiveSessionControllerTests
|
|||
|
||||
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 null;
|
||||
return -1;
|
||||
}
|
||||
|
||||
public ValueTask<NetReceiveResult> ReceiveAsync(
|
||||
Memory<byte> destination,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new OperationCanceledException(cancellationToken);
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -381,12 +381,20 @@ public sealed class LiveSessionHostTests
|
|||
public void Send(ReadOnlySpan<byte> datagram) { }
|
||||
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 null;
|
||||
return -1;
|
||||
}
|
||||
|
||||
public ValueTask<NetReceiveResult> ReceiveAsync(
|
||||
Memory<byte> destination,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new OperationCanceledException(cancellationToken);
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,12 +99,20 @@ public sealed class LiveSessionLifecycleHostTests
|
|||
|
||||
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 null;
|
||||
return -1;
|
||||
}
|
||||
|
||||
public ValueTask<NetReceiveResult> ReceiveAsync(
|
||||
Memory<byte> destination,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new OperationCanceledException(cancellationToken);
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,4 +96,50 @@ public class NetClientTests
|
|||
Assert.Null(result);
|
||||
Assert.Null(from);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReceiveAsync_LoopbackWritesIntoCallerOwnedMemory()
|
||||
{
|
||||
using var serverSide =
|
||||
new NetClient(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
using var clientSide =
|
||||
new NetClient(new IPEndPoint(
|
||||
IPAddress.Loopback,
|
||||
serverSide.LocalEndPoint.Port));
|
||||
byte[] outbound = Enumerable.Range(0, 1_500)
|
||||
.Select(static value => (byte)value)
|
||||
.ToArray();
|
||||
byte[] destination = new byte[2_048];
|
||||
using var timeout =
|
||||
new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
|
||||
clientSide.Send(outbound);
|
||||
NetReceiveResult result = await serverSide.ReceiveAsync(
|
||||
destination,
|
||||
timeout.Token);
|
||||
|
||||
Assert.Equal(outbound.Length, result.Length);
|
||||
Assert.Equal(
|
||||
outbound,
|
||||
destination.AsSpan(0, result.Length).ToArray());
|
||||
Assert.Equal(
|
||||
clientSide.LocalEndPoint.Port,
|
||||
result.RemoteEndPoint.Port);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReceiveAsync_IdleCancellationStopsOutstandingReceive()
|
||||
{
|
||||
using var client =
|
||||
new NetClient(new IPEndPoint(IPAddress.Loopback, 1));
|
||||
byte[] destination = new byte[32];
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
|
||||
ValueTask<NetReceiveResult> receive =
|
||||
client.ReceiveAsync(destination, cancellation.Token);
|
||||
cancellation.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
async () => await receive);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,12 +30,20 @@ public sealed class WorldSessionConstructionTests
|
|||
|
||||
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 null;
|
||||
return -1;
|
||||
}
|
||||
|
||||
public ValueTask<NetReceiveResult> ReceiveAsync(
|
||||
Memory<byte> destination,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new OperationCanceledException(cancellationToken);
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() { }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue