acdream/tests/AcDream.Core.Net.Tests/NetClientTests.cs
Erik 7211bb1bf7 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.
2026-07-25 05:50:18 +02:00

145 lines
5.7 KiB
C#

using System.Net;
using System.Net.Sockets;
using System.Reflection;
using AcDream.Core.Net;
namespace AcDream.Core.Net.Tests;
public class NetClientTests
{
[Fact]
public void Construction_AppliesWindowsConnResetMitigationWithoutThrowing()
{
// 2026-07-24 audit fix: SIO_UDP_CONNRESET must be disabled on
// Windows so a delayed ICMP port-unreachable from an earlier Send
// can't surface as a WSAECONNRESET SocketException on a later,
// unrelated Receive call. This is a smoke test — if the IOControl
// call regresses (wrong control code / wrong overload), the
// constructor itself throws here instead of only manifesting much
// later as a silently dead receive loop under real network churn.
using var client = new NetClient(new IPEndPoint(IPAddress.Loopback, 0));
Assert.NotNull(client.LocalEndPoint);
}
[Fact]
public void Receive_RepeatedSameTimeout_CachesLastAppliedValue()
{
// The setsockopt-equivalent (Socket.ReceiveTimeout assignment) used
// to run on every single Receive call (~4x/sec at the 250ms
// heartbeat cadence). It's now hoisted behind a last-applied-value
// cache. This test can't observe the syscall directly, but it does
// prove the cache field converges on the requested value and the
// underlying socket option stays in sync — a regression that
// stopped updating the cache (or stopped applying to the socket)
// would fail this.
using var client = new NetClient(new IPEndPoint(IPAddress.Loopback, 1));
client.Receive(TimeSpan.FromMilliseconds(50), out _);
client.Receive(TimeSpan.FromMilliseconds(50), out _);
FieldInfo cacheField = typeof(NetClient).GetField(
"_lastAppliedReceiveTimeoutMs", BindingFlags.NonPublic | BindingFlags.Instance)!;
Assert.Equal(50, (int)cacheField.GetValue(client)!);
FieldInfo udpField = typeof(NetClient).GetField(
"_udp", BindingFlags.NonPublic | BindingFlags.Instance)!;
var udp = (UdpClient)udpField.GetValue(client)!;
Assert.Equal(50, udp.Client.ReceiveTimeout);
}
[Fact]
public void Receive_DifferingTimeouts_UpdatesCachedValueEachTime()
{
using var client = new NetClient(new IPEndPoint(IPAddress.Loopback, 1));
client.Receive(TimeSpan.FromMilliseconds(40), out _);
client.Receive(TimeSpan.FromMilliseconds(120), out _);
FieldInfo cacheField = typeof(NetClient).GetField(
"_lastAppliedReceiveTimeoutMs", BindingFlags.NonPublic | BindingFlags.Instance)!;
Assert.Equal(120, (int)cacheField.GetValue(client)!);
FieldInfo udpField = typeof(NetClient).GetField(
"_udp", BindingFlags.NonPublic | BindingFlags.Instance)!;
var udp = (UdpClient)udpField.GetValue(client)!;
Assert.Equal(120, udp.Client.ReceiveTimeout);
}
[Fact]
public void SendReceive_LoopbackSelfTest_EchoRoundTrip()
{
// Two NetClients on loopback that send messages to each other.
// Proves Send + Receive actually work end-to-end over real UDP
// without depending on any remote server.
using var serverSide = new NetClient(new IPEndPoint(IPAddress.Loopback, 0));
using var clientSide = new NetClient(new IPEndPoint(IPAddress.Loopback, serverSide.LocalEndPoint.Port));
byte[] outbound = { 0xDE, 0xAD, 0xBE, 0xEF };
clientSide.Send(outbound);
var received = serverSide.Receive(TimeSpan.FromSeconds(2), out var from);
Assert.NotNull(received);
Assert.Equal(outbound, received);
Assert.NotNull(from);
Assert.Equal(IPAddress.Loopback, from!.Address);
Assert.Equal(clientSide.LocalEndPoint.Port, from.Port);
}
[Fact]
public void Receive_Timeout_ReturnsNullAndNoException()
{
// Listen for something that will never arrive; the timeout path
// must return null cleanly rather than leaking a SocketException.
using var client = new NetClient(new IPEndPoint(IPAddress.Loopback, 1));
var result = client.Receive(TimeSpan.FromMilliseconds(100), out var from);
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);
}
}